From 9de99a9ef45fe89e345af6951beb2f456fb7f39b Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 30 Jun 2017 16:55:37 +0700 Subject: [PATCH] Support post, reply, edit, load and view more loading states via global css classnames --- .../src/components/Comment.js | 39 +++---- .../src/components/EditableCommentContent.js | 91 ++++++++------- .../src/components/LoadMore.js | 27 +++-- .../src/components/Stream.js | 25 +++-- client/coral-embed-stream/style/default.css | 24 ++-- client/coral-framework/utils/index.js | 18 +++ client/coral-plugin-commentbox/CommentBox.js | 47 ++++---- client/coral-plugin-commentbox/CommentForm.js | 105 ++++++++++-------- client/coral-plugin-commentbox/styles.css | 94 +++++++++++++++- client/coral-plugin-replies/ReplyBox.js | 2 +- client/coral-ui/components/Button.css | 14 ++- locales/en.yml | 2 +- locales/es.yml | 2 +- 13 files changed, 312 insertions(+), 178 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 7a8b13312..2e854633b 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -24,7 +24,7 @@ import CommentContent from './CommentContent'; import Slot from 'coral-framework/components/Slot'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import {EditableCommentContent} from './EditableCommentContent'; -import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils'; +import {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils'; import t from 'coral-framework/services/i18n'; const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF'); @@ -75,7 +75,6 @@ const ActionButton = ({children}) => { }; export default class Comment extends React.Component { - isLoadingReplies = false; constructor(props) { super(props); @@ -90,6 +89,7 @@ export default class Comment extends React.Component { isEditing: false, replyBoxVisible: false, animateEnter: false, + loadingState: '', ...resetCursors({}, props), }; } @@ -220,24 +220,23 @@ export default class Comment extends React.Component { } loadNewReplies = () => { - if (!this.isLoadingReplies) { - this.isLoadingReplies = true; - const {replies, replyCount, id} = this.props.comment; - if (replyCount > replies.nodes.length) { - this.props.loadMore(id) - .then(() => { - this.setState(resetCursors(this.state, this.props)); - this.isLoadingReplies = false; - }) - .catch((e) => { - this.isLoadingReplies = false; - throw e; + const {replies, replyCount, id} = this.props.comment; + if (replyCount > replies.nodes.length) { + this.setState({loadingState: 'loading'}); + this.props.loadMore(id) + .then(() => { + this.setState({ + ...resetCursors(this.state, this.props), + loadingState: 'success', }); - return; - } - this.setState(resetCursors); - this.isLoadingReplies = false; + }) + .catch((error) => { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + }); + return; } + this.setState(resetCursors); }; showReplyBox = () => { @@ -331,6 +330,7 @@ export default class Comment extends React.Component { } = this.props; const view = this.getVisibileReplies(); + const {loadingState} = this.state; const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length); const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount; @@ -595,12 +595,13 @@ export default class Comment extends React.Component { />; })} -
+
diff --git a/client/coral-embed-stream/src/components/EditableCommentContent.js b/client/coral-embed-stream/src/components/EditableCommentContent.js index 5e4e354db..7e6035d58 100644 --- a/client/coral-embed-stream/src/components/EditableCommentContent.js +++ b/client/coral-embed-stream/src/components/EditableCommentContent.js @@ -5,6 +5,7 @@ import styles from './Comment.css'; import {CountdownSeconds} from './CountdownSeconds'; import {getEditableUntilDate} from './util'; import {can} from 'coral-framework/services/perms'; +import {forEachError} from 'coral-framework/utils'; import {Icon} from 'coral-ui'; import t from 'coral-framework/services/i18n'; @@ -46,9 +47,14 @@ export class EditableCommentContent extends React.Component { // called when editing should be stopped stopEditing: React.PropTypes.func, } + constructor(props) { super(props); this.editWindowExpiryTimeout = null; + this.state = { + body: props.comment.body, + loadingState: '', + }; } componentDidMount() { const editableUntil = getEditableUntilDate(this.props.comment); @@ -65,74 +71,75 @@ export class EditableCommentContent extends React.Component { this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); } } - editComment = async (edit) => { + + handleBodyChange = (body) => { + this.setState({body}); + } + + handleSubmit = async () => { if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { this.props.addNotification('error', t('error.NOT_AUTHORIZED')); return; } + this.setState({loadingState: 'loading'}); + const {editComment, addNotification, stopEditing} = this.props; if (typeof editComment !== 'function') {return;} let response; - let successfullyEdited = false; try { - response = await editComment(edit); - const errors = (response && response.data && response.data.editComment) - ? response.data.editComment.errors - : null; - if (errors && (errors.length === 1)) { - throw errors[0]; - } - successfullyEdited = true; - } catch (error) { - const errors = error.errors || [error]; - errors.forEach((e) => { - if (e.translation_key) { - addNotification('error', t(`error.${e.translation_key}`)); - } else if (error.networkError) { - addNotification('error', t('error.network_error')); - } else { - addNotification('error', t('edit_comment.unexpected_error')); - console.error(e); - } - }); - } - if (successfullyEdited) { + response = await editComment({body: this.state.body}); + this.setState({loadingState: 'success'}); const status = response.data.editComment.comment.status; notifyForNewCommentStatus(this.props.addNotification, status); - } - if (successfullyEdited && typeof stopEditing === 'function') { - stopEditing(); + if (typeof stopEditing === 'function') { + stopEditing(); + } + } catch (error) { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => addNotification('error', msg)); } } + + getEditableUntil = (props = this.props) => { + return getEditableUntilDate(props.comment); + } + + isEditWindowExpired = (props = this.props) => { + return (this.getEditableUntil(props) - new Date()) < 0; + } + + isSubmitEnabled = (comment) => { + + // should be disabled if user hasn't actually changed their + // original comment + return (comment.body !== this.props.comment.body) && !this.isEditWindowExpired(); + } + render() { - const originalBody = this.props.comment.body; - const editableUntil = getEditableUntilDate(this.props.comment); - const editWindowExpired = (editableUntil - new Date()) < 0; return (
{ - - // should be disabled if user hasn't actually changed their - // original comment - return (comment.body !== originalBody) && !editWindowExpired; - }} - saveComment={this.editComment} + submitEnabled={this.isSubmitEnabled} + body={this.state.body} + onBodyChange={this.handleBodyChange} + onSubmit={this.handleSubmit} bodyLabel={t('edit_comment.body_input_label')} bodyPlaceholder="" submitText={{t('edit_comment.save_button')}} - saveButtonCStyle="green" - cancelButtonClicked={this.props.stopEditing} - buttonClass={styles.button} + submitButtonCStyle="green" + onCancel={this.props.stopEditing} + submitButtonClassName={styles.button} + cancelButtonClassName={styles.button} + loadingState={this.state.loadingState} buttonContainerStart={
{ - editWindowExpired + this.isEditWindowExpired() ? {t('edit_comment.edit_window_expired')} { @@ -144,7 +151,7 @@ export class EditableCommentContent extends React.Component { : {t('edit_comment.edit_window_timer_prefix')} (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' } /> diff --git a/client/coral-embed-stream/src/components/LoadMore.js b/client/coral-embed-stream/src/components/LoadMore.js index 3d1ad4679..e05931581 100644 --- a/client/coral-embed-stream/src/components/LoadMore.js +++ b/client/coral-embed-stream/src/components/LoadMore.js @@ -1,12 +1,10 @@ import React, {PropTypes} from 'react'; import {Button} from 'coral-ui'; import t from 'coral-framework/services/i18n'; +import cn from 'classnames'; class LoadMore extends React.Component { - - componentDidMount () { - this.initialState = true; - } + initialState = true; replyCountFormat = (count) => { if (!count) { @@ -23,17 +21,22 @@ class LoadMore extends React.Component { } } - loadMore = () => { - this.initialState = false; - this.props.loadMore(); + componentWillReceiveProps(nextProps) { + if (['success', 'error'].indexOf(nextProps.loadingState) >= 0) { + this.initialState = false; + } } render () { - const {topLevel, moreComments, replyCount} = this.props; + const {topLevel, moreComments, replyCount, loadingState, loadMore} = this.props; + const disabled = loadingState === 'loading'; return moreComments - ?
+ ?
@@ -44,7 +47,9 @@ class LoadMore extends React.Component { LoadMore.propTypes = { replyCount: PropTypes.number, topLevel: PropTypes.bool.isRequired, - loadMore: PropTypes.func.isRequired + loadMore: PropTypes.func.isRequired, + moreComments: PropTypes.bool, + loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']), }; export default LoadMore; diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 165b3ce4c..0245466a5 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -14,6 +14,7 @@ import QuestionBox from 'coral-plugin-questionbox/QuestionBox'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import NewCount from './NewCount'; import {TransitionGroup} from 'react-transition-group'; +import {forEachError} from 'coral-framework/utils'; const hasComment = (nodes, id) => nodes.some((node) => node.id === id); @@ -53,13 +54,12 @@ function invalidateCursor(invalidated, state, props) { class Stream extends React.Component { - isLoadingMore = false; - constructor(props) { super(props); this.state = { ...resetCursors(this.state, props), keepCommentBox: false, + loadingState: '', }; } @@ -107,15 +107,15 @@ class Stream extends React.Component { }; loadMoreComments = () => { - if (!this.isLoadingMore) { - this.isLoadingMore = true; - this.props.loadMoreComments() - .then(() => this.isLoadingMore = false) - .catch((e) => { - this.isLoadingMore = false; - throw e; - }); - } + this.setState({loadingState: 'loading'}); + this.props.loadMoreComments() + .then(() => { + this.setState({loadingState: 'success'}); + }) + .catch((error) => { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + }); } // getVisibileComments returns a list containing comments @@ -163,7 +163,7 @@ class Stream extends React.Component { pluginProps, editName } = this.props; - const {keepCommentBox} = this.state; + const {keepCommentBox, loadingState} = this.state; const view = this.getVisibleComments(); const open = asset.closedAt === null; @@ -317,6 +317,7 @@ class Stream extends React.Component { topLevel={true} moreComments={asset.comments.hasNextPage} loadMore={this.loadMoreComments} + loadingState={loadingState} />
}
diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index a752493a3..9b1232e3b 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -197,12 +197,12 @@ hr { } /* Comment Box Styles */ -.coral-plugin-commentbox-container { +.talk-plugin-commentbox-container { display: flex; width: 100%; } -.coral-plugin-commentbox-textarea { +.talk-plugin-commentbox-textarea { color: #262626; flex: 1; padding: 1em; @@ -212,13 +212,13 @@ hr { border: 1px solid #9E9E9E; } -.coral-plugin-commentbox-button-container { +.talk-plugin-commentbox-button-container { display: flex; justify-content: flex-end; margin-top: 10px; } -.coral-plugin-commentbox-button { +.talk-plugin-commentbox-button { float: right; margin-top: 10px; padding: 5px 10px; @@ -228,18 +228,18 @@ hr { border-radius: 2px; } -.coral-plugin-commentbox-username { +.talk-plugin-commentbox-username { width: 50%; padding-left: 5px; margin-bottom: 5px; } -.coral-plugin-commentbox-char-count { +.talk-plugin-commentbox-char-count { color: #ccc; text-align: right; } -.coral-plugin-commentbox-char-max { +.talk-plugin-commentbox-char-max { color: #d50000; } @@ -417,11 +417,11 @@ button.comment__action-button[disabled], /* Load More */ -.coral-load-more { +.talk-load-more { text-align: center; } -.coral-load-more button { +.talk-load-more button { text-align: center; color: #FFF; background-color: #2376D8; @@ -433,11 +433,11 @@ button.comment__action-button[disabled], display: inline-block; } -.coral-load-more:hover button { +.talk-load-more:hover button { background-color: #4399FF; } -.coral-load-more-replies, .coral-new-comments { +.talk-load-more-replies, .coral-new-comments { width: 100%; display: flex; justify-content: center; @@ -450,7 +450,7 @@ button.comment__action-button[disabled], z-index: 100; } -.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{ +.talk-load-more-replies button.talk-load-more, .coral-new-comments button.talk-load-more{ width: initial; } diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 0b79b7b02..1866005be 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -1,4 +1,5 @@ import {gql} from 'react-apollo'; +import t from 'coral-framework/services/i18n'; export const getTotalActionCount = (type, comment) => { return comment.action_summaries @@ -126,3 +127,20 @@ export function createDefaultResponseFragments(...names) { }); return result; } + +export function forEachError(error, callback) { + const errors = error.errors || [error]; + errors.forEach((e) => { + console.error(e); + + let msg = ''; + if (e.translation_key) { + msg = t(`error.${e.translation_key}`); + } else if (error.networkError) { + msg = t('error.network_error'); + } else { + msg = t('error.unexpected'); + } + callback({error: e, msg}); + }); +} diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index efa1d27ed..6e17b119f 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -2,12 +2,13 @@ import React, {PropTypes} from 'react'; import t from 'coral-framework/services/i18n'; import {can} from 'coral-framework/services/perms'; +import {forEachError} from 'coral-framework/utils'; import Slot from 'coral-framework/components/Slot'; import {connect} from 'react-redux'; import {CommentForm} from './CommentForm'; -export const name = 'coral-plugin-commentbox'; +export const name = 'talk-plugin-commentbox'; // Given a newly posted comment's status, show a notification to the user // if needed @@ -28,16 +29,17 @@ class CommentBox extends React.Component { this.state = { username: '', + body: '', + loadingState: '', - // incremented on successful post to clear form - postedCount: 0, hooks: { preSubmit: [], postSubmit: [] } }; } - postComment = ({body}) => { + + handleSubmit = () => { const { commentPostedHandler, postComment, @@ -55,15 +57,17 @@ class CommentBox extends React.Component { let comment = { asset_id: assetId, parent_id: parentId, - body, + body: this.state.body, ...this.props.commentBox }; // Execute preSubmit Hooks this.state.hooks.preSubmit.forEach((hook) => hook()); + this.setState({loadingState: 'loading'}); postComment(comment, 'comments') .then(({data}) => { + this.setState({loadingState: 'success'}); const postedComment = data.createComment.comment; // Execute postSubmit Hooks @@ -74,12 +78,17 @@ class CommentBox extends React.Component { if (commentPostedHandler) { commentPostedHandler(); } + + this.setState({body: ''}); }) .catch((err) => { - console.error(err); + this.setState({loadingState: 'error'}); + forEachError(err, ({msg}) => addNotification('error', msg)); }); + } - this.setState({postedCount: this.state.postedCount + 1}); + handleBodyChange = (body) => { + this.setState({body}); } registerHook = (hookType = '', hook = () => {}) => { @@ -130,21 +139,17 @@ class CommentBox extends React.Component { }); } - handleChange = (e) => this.setState({body: e.target.value}); - render () { - const {styles, isReply, currentUser, maxCharCount} = this.props; - let {cancelButtonClicked} = this.props; + const {isReply, maxCharCount} = this.props; + let {onCancel} = this.props; - if (isReply && typeof cancelButtonClicked !== 'function') { - console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply'); - cancelButtonClicked = () => {}; + if (isReply && typeof onCancel !== 'function') { + console.warn('the CommentBox component should have a onCancel callback defined if it lives in a Reply'); + onCancel = () => {}; } return
} - cancelButtonClicked={cancelButtonClicked} + onBodyChange={this.handleBodyChange} + loadingState={this.state.loadingState} + onCancel={onCancel} + onSubmit={this.handleSubmit} />
; } @@ -174,12 +182,13 @@ CommentBox.propTypes = { maxCharCount: PropTypes.number, commentPostedHandler: PropTypes.func, postComment: PropTypes.func.isRequired, - cancelButtonClicked: PropTypes.func, + onCancel: PropTypes.func, assetId: PropTypes.string.isRequired, parentId: PropTypes.string, currentUser: PropTypes.object.isRequired, isReply: PropTypes.bool.isRequired, canPost: PropTypes.bool, + addNotification: PropTypes.func.isRequired, }; const mapStateToProps = ({commentBox}) => ({commentBox}); diff --git a/client/coral-plugin-commentbox/CommentForm.js b/client/coral-plugin-commentbox/CommentForm.js index 4ce32c11f..fe0f9f6d9 100644 --- a/client/coral-plugin-commentbox/CommentForm.js +++ b/client/coral-plugin-commentbox/CommentForm.js @@ -1,9 +1,10 @@ import React, {PropTypes} from 'react'; import {Button} from 'coral-ui'; -import classnames from 'classnames'; +import cn from 'classnames'; import Slot from 'coral-framework/components/Slot'; import {name} from './CommentBox'; +import styles from './styles.css'; import t from 'coral-framework/services/i18n'; @@ -13,15 +14,8 @@ import t from 'coral-framework/services/i18n'; export class CommentForm extends React.Component { static propTypes = { - // Initial value for underlying comment body textarea - defaultValue: PropTypes.string, charCountEnable: PropTypes.bool.isRequired, maxCharCount: PropTypes.number, - cancelButtonClicked: PropTypes.func, - - // Save the comment in the form. - // Will be passed { body: String } - saveComment: PropTypes.func.isRequired, // DOM ID for form input that edits comment body bodyInputId: PropTypes.string, @@ -38,53 +32,63 @@ export class CommentForm extends React.Component { // render inside submit button submitText: PropTypes.node, - styles: PropTypes.shape({ - textarea: PropTypes.string - }), + // cStyle for enabled submit + submitButtonCStyle: PropTypes.string, - // cStyle for enabled save - saveButtonCStyle: PropTypes.string, - - // return whether the save button should be enabled for the provided + // return whether the submit button should be enabled for the provided // comment ({ body }) (for reasons other than charCount) - saveCommentEnabled: PropTypes.func, + submitEnabled: PropTypes.func, // className to add to buttons - buttonClass: PropTypes.string, + submitButtonClassName: PropTypes.string, + cancelButtonClassName: PropTypes.string, + + body: PropTypes.string.isRequired, + onBodyChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func, + state: PropTypes.string, + loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']), } static get defaultProps() { return { bodyLabel: t('comment_box.comment'), bodyPlaceholder: t('comment_box.comment'), submitText: t('comment_box.post'), - saveButtonCStyle: 'darkGrey', - saveCommentEnabled: () => true, + submitButtonCStyle: 'darkGrey', + submitEnabled: () => true, }; } - constructor(props) { - super(props); - this.onBodyChange = this.onBodyChange.bind(this); - this.onClickSubmit = this.onClickSubmit.bind(this); - this.state = { - body: props.defaultValue || '' - }; - } - onBodyChange(e) { - this.setState({body: e.target.value}); - } - onClickSubmit(e) { - e.preventDefault(); - const {saveComment} = this.props; - const {body} = this.state; - saveComment({body}); - } - render() { - const {maxCharCount, styles, saveCommentEnabled, buttonClass, charCountEnable} = this.props; - const body = this.state.body; + onBodyChange = (e) => { + this.props.onBodyChange(e.target.value); + } + + onClickSubmit = () => { + this.props.onSubmit(); + } + + getButtonClassName = () => { + switch (this.props.loadingState) { + case 'loading': + return cn(`${name}-button-loading`, styles.buttonLoading); + case 'success': + return cn(`${name}-button-success`, styles.buttonSuccess); + case 'error': + return cn(`${name}-button-error`, styles.buttonError); + default: + return ''; + } + } + + render() { + const {maxCharCount, submitEnabled, cancelButtonClassName, submitButtonClassName, charCountEnable, body, loadingState} = this.props; + const length = body.length; const isRespectingMaxCount = (length) => charCountEnable && maxCharCount && length > maxCharCount; - const disablePostComment = !length || isRespectingMaxCount(length) || !saveCommentEnabled({body}); + const disableSubmitButton = !length || isRespectingMaxCount(length) || !submitEnabled({body}) || loadingState === 'loading'; + const disableCancelButton = loadingState === 'loading'; + const disableTextArea = loadingState === 'loading'; return
@@ -95,13 +99,14 @@ export class CommentForm extends React.Component { {this.props.bodyLabel}