Merge branch 'master' into subscribe-mod-actions

Conflicts:
	client/coral-admin/src/routes/Moderation/components/Moderation.js
	client/coral-admin/src/routes/Moderation/components/styles.css
	client/coral-admin/src/routes/Moderation/containers/Moderation.js
This commit is contained in:
Chi Vinh Le
2017-06-19 23:52:10 +07:00
24 changed files with 522 additions and 86 deletions
@@ -51,3 +51,16 @@ export const toggleSelectCommentInUserDetail = (id, active) => {
id
};
};
export const toggleStorySearch = (active) => ({
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH
});
export const storySearchChange = (value) => ({
type: actions.STORY_SEARCH_CHANGE_VALUE,
value
});
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE
});
@@ -1,6 +1,5 @@
.layout {
max-width: 1280px;
margin: 0 auto;
overflow: hidden;
background-color: #FAFAFA;
}
@@ -10,7 +10,7 @@ const Layout = ({
toggleShortcutModal,
restricted = false,
...props}) => (
<LayoutMDL fixedDrawer>
<LayoutMDL className={styles.layout} fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
@@ -12,3 +12,7 @@ export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
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';
@@ -12,6 +12,8 @@ const initialState = fromJS({
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
userDetailSelectedIds: new Set(),
banDialog: false,
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
suspendUserDialog: {
@@ -25,6 +27,8 @@ const initialState = fromJS({
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
return initialState;
case actions.HIDE_BANUSER_DIALOG:
return state
.set('banDialog', false)
@@ -80,6 +84,12 @@ export default function moderation (state = initialState, action) {
return state.update('userDetailSelectedIds', (set) => set.add(action.id));
case actions.UNSELECT_USER_DETAIL_COMMENT:
return state.update('userDetailSelectedIds', (set) => set.delete(action.id));
case actions.SHOW_STORY_SEARCH:
return state.set('storySearchVisible', true);
case actions.HIDE_STORY_SEARCH:
return state.set('storySearchVisible', false);
case actions.STORY_SEARCH_CHANGE_VALUE:
return state.set('storySearchString', action.value);
case actions.SET_SORT_ORDER:
return state.set('sortOrder', action.order);
default :
@@ -91,6 +91,7 @@ span {
.moderateAsset {
a {
text-align: center;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
@@ -103,21 +104,9 @@ span {
opacity: 1;
&:hover {
opacity: .8;
opacity: .8;
cursor: pointer;
}
&:first-child {
text-align: left;
}
&:nth-child(2) {
text-align: center;
}
&:last-child {
text-align: right;
}
}
}
}
@@ -10,6 +10,8 @@ import ModerationHeader from './ModerationHeader';
import NotFoundAsset from './NotFoundAsset';
import ModerationKeysModal from '../../../components/ModerationKeysModal';
import UserDetail from '../containers/UserDetail';
import StorySearch from '../containers/StorySearch';
import {Spinner} from 'coral-ui';
export default class Moderation extends Component {
state = {
@@ -32,6 +34,15 @@ export default class Moderation extends Component {
this.toggleModal(false);
}
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
}
openSearch = () => {
this.props.toggleStorySearch(true);
}
moderate = (accept) => () => {
const {acceptComment, rejectComment} = this.props;
const {selectedIndex} = this.state;
@@ -92,17 +103,22 @@ export default class Moderation extends Component {
}
render () {
const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
const providedAssetId = this.props.params.id;
let asset;
const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
const providedAssetId = this.props.params.id;
const {asset} = root;
if (providedAssetId) {
asset = assets.find((asset) => asset.id === this.props.params.id);
if (asset === null) {
if (!asset) {
// Not found.
return <NotFoundAsset assetId={providedAssetId} />;
}
if (asset === undefined || asset.id !== providedAssetId) {
// Still loading.
return <Spinner />;
}
}
const comments = root[activeTab];
@@ -127,7 +143,12 @@ export default class Moderation extends Component {
return (
<div>
<ModerationHeader asset={asset} />
<ModerationHeader
searchVisible={this.props.moderation.storySearchVisible}
openSearch={this.openSearch}
closeSearch={this.closeSearch}
asset={asset}
/>
<ModerationMenu
asset={asset}
allCount={root.allCount}
@@ -194,6 +215,11 @@ export default class Moderation extends Component {
acceptComment={props.acceptComment}
rejectComment={props.rejectComment} />
)}
<StorySearch
moderation={this.props.moderation}
closeSearch={this.closeSearch}
storySearchChange={this.props.storySearchChange}
/>
</div>
);
}
@@ -1,30 +1,35 @@
import React from 'react';
import {Link} from 'react-router';
import React, {PropTypes} from 'react';
import {Icon} from 'coral-ui';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
const ModerationHeader = (props) => (
const ModerationHeader = ({asset, searchVisible, openSearch, closeSearch}) => {
const trigger = searchVisible ? closeSearch : openSearch;
const searchTriggerIcon = <Icon className={styles.searchTrigger} name={searchVisible ? 'arrow_drop_up' : 'arrow_drop_down'} />;
const title = asset
? <a onClick={trigger} className="mdl-tabs__tab">{asset.title} {searchTriggerIcon}</a>
: <a onClick={trigger} className="mdl-tabs__tab">{t('modqueue.all_streams')} {searchTriggerIcon}</a>;
return (
<div className=''>
<div className={`mdl-tabs ${styles.header}`}>
{
props.asset ?
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<Link className="mdl-tabs__tab" to="/admin/moderate">{t('modqueue.all_streams')}</Link>
<a className="mdl-tabs__tab" href={props.asset.url} target="_blank">
<span>{props.asset.title}</span>
<Icon className={styles.settingsButton} name="open_in_new"/>
</a>
<Link className="mdl-tabs__tab" to="/admin/stories">Select Stream</Link>
</div>
:
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<a className="mdl-tabs__tab" />
<a className="mdl-tabs__tab">{t('modqueue.all_streams')}</a>
<Link className="mdl-tabs__tab" to="/admin/stories">{t('modqueue.select_stream')}</Link>
</div>
}
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
{title}
</div>
</div>
</div>
);
);
};
ModerationHeader.propTypes = {
asset: PropTypes.shape({
title: PropTypes.string,
id: PropTypes.string
}),
openSearch: PropTypes.func.isRequired,
closeSearch: PropTypes.func.isRequired
};
export default ModerationHeader;
@@ -0,0 +1,30 @@
import React, {PropTypes} from 'react';
import styles from './StorySearch.css';
const formatDate = (date) => {
const d = new Date(date);
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
};
const Story = ({author, title, createdAt, open, id, goToStory}) => {
return (
<li className={styles.story} onClick={() => goToStory(id)}>
<span className={styles.title}>{title}</span>
<div className={styles.meta}>
<span className={styles.author}>By {author}</span>
<span className={styles.createdAt}>{formatDate(createdAt)}</span>
<span className={styles.status}>{open ? 'Open' : 'Closed'}</span>
</div>
</li>
);
};
Story.propTypes = {
id: PropTypes.string.isRequired,
author: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
open: PropTypes.bool.isRequired
};
export default Story;
@@ -0,0 +1,145 @@
.overlay {
position: absolute;
top: 58px;
left: 0;
right: 0;
bottom: 0;
width: 100%;
z-index: 99;
opacity: 0;
}
.container {
position: absolute;
background-color: white;
top: 106px;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
width: 50%;
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
z-index: 100;
max-width: 820px;
}
.positionShim {
left: -50%;
}
.headInput {
background-color: #efefef;
padding: 10px 27px;
box-sizing: border-box;
}
.searchInput {
width: calc(100% - 100px);
padding: 8px;
height: 100%;
font-size: 16px;
margin-right: 5px;
position: relative;
top: 3px;
box-sizing: border-box;
border-radius: 3px;
border: solid 1px #dfdfdf;
max-height: 45px;
max-width: 600px;
font-weight: 300;
}
.cta {
letter-spacing: 1px;
font-weight: bold;
font-size: 15px;
margin: 0;
height: 50px;
box-sizing: border-box;
font-size: 15px;
font-weight: 500;
padding: 12px 30px;
letter-spacing: 0.25px;
}
/*.storyList {
border-top: 1px solid #ddd;
}*/
.story {
padding: 7px 50px;
border-bottom: 1px solid #ddd;
cursor: pointer;
display: block;
text-decoration: none;
height: 50px;
box-sizing: border-box;
transition: background-color 400ms;
&:hover {
background-color: #efefef;
}
&:last-child {
border-bottom: none;
}
}
.title, .meta {
margin: 0;
color: black;
font-size: 15px;
}
.author, .createdAt, .status {
font-size: 17px;
display: inline-block;
font-size: .8em;
}
.createdAt {
text-align: center;
}
.author {
display: inline-block;
width: 200px;
color: #aaa;
}
.createdAt {
display: inline-block;
width: 200px;
color: #aaa;
}
.searchButton {
width: 90px;
height: 35px;
}
.searchResults {
padding: 7px 27px;
background: #F5F5F5;
}
.searchResults i {
font-size: 16px;
vertical-align: middle;
}
.accessStories {
font-size: 18px;
}
.headlineRecent {
font-size: 15px;
font-weight: 500;
letter-spacing: 0.25px;
vertical-align: middle;
margin-left: 8px;
}
.noResults {
padding: 10px 24px 15px 49px;
}
@@ -0,0 +1,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './StorySearch.css';
import {Button, Spinner, Icon} from 'coral-ui';
import Story from './Story';
const StorySearch = (props) => {
const {
root: {
assets = []
},
data: {loading}
} = props;
if (!props.moderation.storySearchVisible) {
return null;
}
return (
<div>
<div className={styles.container} role='alertdialog' onKeyDown={props.handleEsc}>
<div className={styles.positionShim}>
<div className={styles.headInput}>
<input
className={styles.searchInput}
onChange={props.handleSearchChange}
onKeyDown={props.handleEnter}
value={props.searchValue}
autoFocus
/>
<Button
cStyle='blue'
className={styles.searchButton}
onClick={props.search}
raised >
Search
</Button>
</div>
<div className={styles.results}>
<p className={styles.cta}>Moderate comments on All Stories</p>
<div className={styles.storyList}>
{
props.moderation.storySearchString ? (
<div className={styles.searchResults}>
<Icon name="search" />
<span className={styles.headlineRecent}>Search Results</span>
</div>
) : (
<div className={styles.searchResults}>
<Icon name="access_time" />
<span className={styles.headlineRecent}>Most Recent Stories</span>
</div>
)
}
{
loading
? <Spinner />
: assets.map((story, i) => {
const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date();
return <Story
key={i}
id={story.id}
title={story.title}
createdAt={new Date(story.created_at).toISOString()}
open={storyOpen}
author={story.author}
goToStory={props.goToStory}
/>;
})
}
{assets.length === 0 && <div className={styles.noResults}>No results</div>}
</div>
</div>
</div>
</div>
<div className={styles.overlay} onClick={props.closeSearch} />
</div>
);
};
StorySearch.propTypes = {
search: PropTypes.func.isRequired,
goToStory: PropTypes.func.isRequired,
closeSearch: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
handleSearchChange: PropTypes.func.isRequired
};
export default StorySearch;
@@ -116,29 +116,20 @@ span {
transition: background-color 200ms;
opacity: 1;
&:first-child {
text-align: left;
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 344px;
display: inline-block;
vertical-align: top;
}
&:nth-child(2) {
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 344px;
display: inline-block;
vertical-align: top;
}
}
&:last-child {
text-align: right;
}
}
}
}
@@ -534,3 +525,8 @@ span {
line-height: 1px;
font-weight: 300;
}
.searchTrigger {
position: relative;
top: .3em;
}
@@ -2,7 +2,6 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import isEqual from 'lodash/isEqual';
import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName} from 'coral-framework/utils';
import * as notification from 'coral-admin/src/services/notification';
@@ -14,7 +13,6 @@ import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-fr
import {handleCommentChange} from '../../../graphql/utils';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
import {
toggleModal,
singleView,
@@ -23,9 +21,12 @@ import {
showSuspendUserDialog,
hideSuspendUserDialog,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
} from 'actions/moderation';
import {Spinner} from 'coral-ui';
@@ -123,6 +124,7 @@ class ModerationContainer extends Component {
}
componentWillMount() {
this.props.clearState();
this.props.fetchSettings();
this.subscribeToUpdates();
}
@@ -132,11 +134,6 @@ class ModerationContainer extends Component {
}
componentWillReceiveProps(nextProps) {
const {updateAssets} = this.props;
if(!isEqual(nextProps.root.assets, this.props.root.assets)) {
updateAssets(nextProps.root.assets);
}
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
this.resubscribe();
@@ -325,7 +322,7 @@ const commentConnectionFragment = gql`
`;
const withModQueueQuery = withQuery(gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER) {
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
all: comments(query: {
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
asset_id: $asset_id,
@@ -362,7 +359,7 @@ const withModQueueQuery = withQuery(gql`
}) {
...CoralAdmin_Moderation_CommentConnection
}
assets: assets {
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
url
@@ -398,6 +395,7 @@ const withModQueueQuery = withQuery(gql`
variables: {
asset_id: id,
sort: sortOrder,
allAssets: id === null
}
};
},
@@ -441,23 +439,24 @@ const mapStateToProps = (state) => ({
moderation: state.moderation.toJS(),
settings: state.settings.toJS(),
auth: state.auth.toJS(),
assets: state.assets.get('assets')
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
updateAssets,
fetchSettings,
showBanUserDialog,
hideBanUserDialog,
hideShortcutsNote,
toggleStorySearch,
showSuspendUserDialog,
hideSuspendUserDialog,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
}, dispatch),
});
@@ -0,0 +1,87 @@
import React from 'react';
import {compose, gql} from 'react-apollo';
import StorySearch from '../components/StorySearch';
import {withRouter} from 'react-router';
import withQuery from 'coral-framework/hocs/withQuery';
class StorySearchContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
searchValue: props.moderation.storySearchString
};
}
handleSearchChange = (e) => {
const {value} = e.target;
this.setState({
searchValue: value
});
}
handleEsc = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.props.closeSearch();
}
}
handleEnter = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.search();
}
}
search = () => {
const {searchValue} = this.state;
this.props.storySearchChange(searchValue);
}
goToStory = (id) => {
const {router, closeSearch} = this.props;
router.push(`/admin/moderate/${id}`);
closeSearch();
}
render () {
return (
<StorySearch
search={this.search}
goToStory={this.goToStory}
handleEsc={this.handleEsc}
handleEnter={this.handleEnter}
searchValue={this.state.searchValue}
handleSearchChange={this.handleSearchChange}
{...this.props}
/>
);
}
}
export const withAssetSearchQuery = withQuery(gql`
query SearchStories($value: String = "") {
assets(query: {value: $value, limit: 10}) {
id
title
url
created_at
closedAt
author
}
}
`, {
options: ({moderation: {storySearchString = ''}}) => {
return {
variables: {
value: storySearchString
}
};
}
});
export default compose(
withRouter,
withAssetSearchQuery
)(StorySearchContainer);
+10
View File
@@ -84,6 +84,16 @@
background: #4f5c67;
}
.type--blue {
color: white;
background: #083b97;
}
.type--blue:hover {
color: white;
background: #083b97;
}
.type--darkGrey {
color: white;
background: #616161;