Merge branch 'master' into fix_translation_staff

This commit is contained in:
Wyatt Johnson
2018-04-19 08:59:29 -06:00
committed by GitHub
149 changed files with 4117 additions and 2403 deletions
+14 -2
View File
@@ -2,10 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Router, Route, IndexRedirect, IndexRoute } from 'react-router';
import Configure from 'routes/Configure';
import Install from 'routes/Install';
import Stories from 'routes/Stories';
import Community from 'routes/Community';
import Configure from 'routes/Configure';
import StreamSettings from './routes/Configure/containers/StreamSettings';
import ModerationSettings from './routes/Configure/containers/ModerationSettings';
import TechSettings from './routes/Configure/containers/TechSettings';
import { ModerationLayout, Moderation } from 'routes/Moderation';
import Layout from 'containers/Layout';
@@ -15,7 +20,14 @@ const routes = (
<Route exact path="/admin/install" component={Install} />
<Route path="/admin" component={Layout}>
<IndexRedirect to="/admin/moderate" />
<Route path="configure" component={Configure} />
<Route path="configure" component={Configure}>
<Route path="stream" component={StreamSettings} />
<Route path="moderation" component={ModerationSettings} />
<Route path="tech" component={TechSettings} />
<IndexRedirect to="stream" />
</Route>
<Route path="stories" component={Stories} />
{/* Community Routes */}
+6 -2
View File
@@ -8,6 +8,10 @@ export const clearPending = () => {
return { type: actions.CLEAR_PENDING };
};
export const setActiveSection = section => {
return { type: actions.SET_ACTIVE_SECTION, section };
export const showSaveDialog = () => {
return { type: actions.SHOW_SAVE_DIALOG };
};
export const hideSaveDialog = () => {
return { type: actions.HIDE_SAVE_DIALOG };
};
+1 -8
View File
@@ -5,14 +5,7 @@ export const singleView = () => ({ type: actions.SINGLE_VIEW });
// hide shortcuts note
export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => {
try {
if (localStorage) {
localStorage.setItem('coral:shortcutsNote', 'hide');
}
} catch (e) {
// above will fail in Safari private mode
}
localStorage.setItem('coral:shortcutsNote', 'hide');
dispatch({ type: actions.HIDE_SHORTCUTS_NOTE });
};
+17 -31
View File
@@ -3,7 +3,7 @@ import cn from 'classnames';
import PropTypes from 'prop-types';
import capitalize from 'lodash/capitalize';
import styles from './UserDetail.css';
import AccountHistory from './AccountHistory';
import UserHistory from './UserHistory';
import { Slot } from 'coral-framework/components';
import UserDetailCommentList from '../components/UserDetailCommentList';
import {
@@ -28,26 +28,6 @@ import UserInfoTooltip from './UserInfoTooltip';
import t from 'coral-framework/services/i18n';
class UserDetail extends React.Component {
rejectThenReload = async info => {
await this.props.rejectComment(info);
this.props.data.refetch();
};
acceptThenReload = async info => {
await this.props.acceptComment(info);
this.props.data.refetch();
};
bulkAcceptThenReload = async () => {
await this.props.bulkAccept();
this.props.data.refetch();
};
bulkRejectThenReload = async () => {
await this.props.bulkReject();
this.props.data.refetch();
};
changeTab = tab => {
this.props.changeTab(tab);
};
@@ -110,8 +90,14 @@ class UserDetail extends React.Component {
unbanUser,
unsuspendUser,
modal,
acceptComment,
rejectComment,
bulkAccept,
bulkReject,
} = this.props;
console.log(rejectedComments, totalComments);
// if totalComments is 0, you're dividing by zero
let rejectedPercent = rejectedComments / totalComments * 100;
@@ -286,7 +272,7 @@ class UserDetail extends React.Component {
'talk-admin-user-detail-history-tab'
)}
>
{t('user_detail.account_history')}
{t('user_detail.user_history')}
</Tab>
</TabBar>
@@ -304,12 +290,12 @@ class UserDetail extends React.Component {
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
acceptComment={acceptComment}
rejectComment={rejectComment}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
bulkAcceptThenReload={bulkAccept}
bulkRejectThenReload={bulkReject}
/>
</TabPane>
<TabPane
@@ -322,19 +308,19 @@ class UserDetail extends React.Component {
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
acceptComment={acceptComment}
rejectComment={rejectComment}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
bulkAcceptThenReload={bulkAccept}
bulkRejectThenReload={bulkReject}
/>
</TabPane>
<TabPane
tabId={'history'}
className={'talk-admin-user-detail-history-tab-pane'}
>
<AccountHistory user={user} />
<UserHistory user={user} />
</TabPane>
</TabContent>
</Drawer>
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { murmur3 } from 'murmurhash-js';
import styles from './AccountHistory.css';
import styles from './UserHistory.css';
import cn from 'classnames';
import flatten from 'lodash/flatten';
import orderBy from 'lodash/orderBy';
@@ -43,15 +43,15 @@ const readableDuration = (startDate, endDate) => {
const buildActionResponse = (typename, created_at, until, status) => {
switch (typename) {
case 'UsernameStatusHistory':
return t('account_history.username_status', status);
return t('user_history.username_status', status);
case 'BannedStatusHistory':
return status
? t('account_history.user_banned')
: t('account_history.ban_removed');
? t('user_history.user_banned')
: t('user_history.ban_removed');
case 'SuspensionStatusHistory':
return until
? t('account_history.suspended', readableDuration(created_at, until))
: t('account_history.suspension_removed');
? t('user_history.suspended', readableDuration(created_at, until))
: t('user_history.suspension_removed');
default:
return '-';
}
@@ -62,43 +62,41 @@ const getModerationValue = assignedBy =>
assignedBy.username
) : (
<span>
<Icon name="computer" /> {t('account_history.system')}
<Icon name="computer" /> {t('user_history.system')}
</span>
);
class AccountHistory extends React.Component {
class UserHistory extends React.Component {
render() {
const { user } = this.props;
const userHistory = buildUserHistory(user.state);
return (
<div>
<div className={cn(styles.table, 'talk-admin-account-history')}>
<div className={cn(styles.table, 'talk-admin-user-history')}>
<div
className={cn(
styles.headerRow,
'talk-admin-account-history-header-row'
'talk-admin-user-history-header-row'
)}
>
<div className={styles.headerRowItem}>{t('user_history.date')}</div>
<div className={styles.headerRowItem}>
{t('account_history.date')}
{t('user_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.taken_by')}
{t('user_history.taken_by')}
</div>
</div>
{userHistory.map(
({ __typename, created_at, assigned_by, until, status }) => (
<div
className={cn(styles.row, 'talk-admin-account-history-row')}
className={cn(styles.row, 'talk-admin-user-history-row')}
key={`${__typename}_${murmur3(created_at)}`}
>
<div
className={cn(
styles.item,
'talk-admin-account-history-row-date'
'talk-admin-user-history-row-date'
)}
>
{moment(new Date(created_at)).format('MMM DD, YYYY')}
@@ -107,7 +105,7 @@ class AccountHistory extends React.Component {
className={cn(
styles.item,
styles.action,
'talk-admin-account-history-row-status'
'talk-admin-user-history-row-status'
)}
>
{buildActionResponse(__typename, created_at, until, status)}
@@ -116,7 +114,7 @@ class AccountHistory extends React.Component {
className={cn(
styles.item,
styles.username,
'talk-admin-account-history-row-assigned-by'
'talk-admin-user-history-row-assigned-by'
)}
>
{getModerationValue(assigned_by)}
@@ -130,8 +128,8 @@ class AccountHistory extends React.Component {
}
}
AccountHistory.propTypes = {
UserHistory.propTypes = {
user: PropTypes.object.isRequired,
};
export default AccountHistory;
export default UserHistory;
@@ -2,4 +2,6 @@ const prefix = 'TALK_ADMIN_CONFIGURE';
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
export const SET_ACTIVE_SECTION = `${prefix}_SET_ACTIVE_SECTION`;
export const SHOW_SAVE_DIALOG = `${prefix}_SHOW_SAVE_DIALOG`;
export const HIDE_SAVE_DIALOG = `${prefix}_HIDE_SAVE_DIALOG`;
@@ -148,6 +148,7 @@ UserDetailContainer.propTypes = {
selectedCommentIds: PropTypes.array,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
userId: PropTypes.string,
};
const LOAD_MORE_QUERY = gql`
@@ -245,7 +246,6 @@ export const withUserDetailQuery = withQuery(
options: ({ userId, statuses }) => {
return {
variables: { author_id: userId, statuses },
fetchPolicy: 'network-only',
};
},
skip: ownProps => !ownProps.userId,
+2 -1
View File
@@ -15,7 +15,8 @@ import { hideShortcutsNote } from './actions/moderation';
smoothscroll.polyfill();
function init({ store, localStorage }) {
if (localStorage && localStorage.getItem('coral:shortcutsNote') === 'hide') {
const shouldHide = localStorage.getItem('coral:shortcutsNote') === 'hide';
if (shouldHide) {
store.dispatch(hideShortcutsNote());
}
}
+15 -6
View File
@@ -6,11 +6,23 @@ const initialState = {
canSave: false,
pending: {},
errors: {},
activeSection: 'stream',
saveDialog: false,
};
export default function configure(state = initialState, action) {
switch (action.type) {
case actions.SHOW_SAVE_DIALOG: {
return {
...state,
saveDialog: true,
};
}
case actions.HIDE_SAVE_DIALOG: {
return {
...state,
saveDialog: false,
};
}
case actions.UPDATE_PENDING: {
let next = state;
if (action.updater) {
@@ -40,11 +52,8 @@ export default function configure(state = initialState, action) {
pending: {},
canSave: false,
};
case actions.SET_ACTIVE_SECTION:
return {
...state,
activeSection: action.section,
};
default:
return state;
}
return state;
}
@@ -1,50 +1,37 @@
import React, { Component } from 'react';
import { Button, List, Item } from 'coral-ui';
import styles from './Configure.css';
import StreamSettings from '../containers/StreamSettings';
import ModerationSettings from '../containers/ModerationSettings';
import TechSettings from '../containers/TechSettings';
import t from 'coral-framework/services/i18n';
import { can } from 'coral-framework/services/perms';
import React from 'react';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import { Button, List, Item } from 'coral-ui';
import { can } from 'coral-framework/services/perms';
import styles from './Configure.css';
import SaveChangesDialog from './SaveChangesDialog';
export default class Configure extends Component {
getSectionComponent(section) {
switch (section) {
case 'stream':
return StreamSettings;
case 'moderation':
return ModerationSettings;
case 'tech':
return TechSettings;
}
throw new Error(`Unknown section ${section}`);
}
class Configure extends React.Component {
render() {
const {
currentUser,
canSave,
savePending,
setActiveSection,
activeSection,
} = this.props;
const SectionComponent = this.getSectionComponent(activeSection);
const { canSave, currentUser, root, savePending, settings } = this.props;
if (!can(currentUser, 'UPDATE_CONFIG')) {
return (
<p>
You must be an administrator to access config settings. Please find
the nearest Admin and ask them to level you up!
</p>
);
return <p>{t('configure.access_message')}</p>;
}
const passProps = {
root,
settings,
};
return (
<div className={styles.container}>
<SaveChangesDialog
saveDialog={this.props.saveDialog}
hideSaveDialog={this.props.hideSaveDialog}
saveChanges={this.props.saveChanges}
discardChanges={this.props.discardChanges}
/>
<div className={styles.leftColumn}>
<List onChange={setActiveSection} activeItem={activeSection}>
<List
onChange={this.props.handleSectionChange}
activeItem={this.props.activeSection}
>
<Item itemId="stream" icon="speaker_notes">
{t('configure.stream_settings')}
</Item>
@@ -74,10 +61,7 @@ export default class Configure extends Component {
</div>
</div>
<div className={styles.mainContent}>
<SectionComponent
root={this.props.root}
settings={this.props.settings}
/>
{React.cloneElement(this.props.children, passProps)}
</div>
</div>
);
@@ -86,10 +70,17 @@ export default class Configure extends Component {
Configure.propTypes = {
savePending: PropTypes.func.isRequired,
saveChanges: PropTypes.func.isRequired,
discardChanges: PropTypes.func.isRequired,
currentUser: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
setActiveSection: PropTypes.func.isRequired,
handleSectionChange: PropTypes.func.isRequired,
activeSection: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
saveDialog: PropTypes.bool,
hideSaveDialog: PropTypes.func.isRequired,
};
export default Configure;
@@ -0,0 +1,40 @@
.buttonActions {
padding-top: 15px;
text-align: right;
}
.dialog {
padding: 25px;
min-width: 400px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
}
.title {
font-size: 18px;
font-weight: 800;
margin-bottom: 20px;
}
.cancel {
color: #363636;
margin-right: 15px;
display: inline-block;
&:hover {
cursor: pointer;
}
}
.button {
margin-left: 5px;
}
@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button, Dialog } from 'coral-ui';
import styles from './SaveChangesDialog.css';
import t from 'coral-framework/services/i18n';
const SaveChangesDialog = ({
saveDialog,
hideSaveDialog,
saveChanges,
discardChanges,
}) => (
<Dialog
className={cn(styles.dialog, 'talk-admin-configure-save-dialog')}
id="saveDialog"
open={saveDialog}
onCancel={hideSaveDialog}
>
<span className={styles.close} onClick={hideSaveDialog}>
×
</span>
<div className={styles.title}>
{t('configure.save_changes_dialog.unsaved_changes')}
</div>
{t('configure.save_changes_dialog.copy')}
<div
className={cn(
styles.buttonActions,
'talk-admin-configure-save-dialog-button-actions'
)}
>
<a className={styles.cancel} onClick={hideSaveDialog}>
Cancel
</a>
<Button onClick={discardChanges} className={styles.button}>
{t('configure.save_changes_dialog.discard')}
</Button>
<Button onClick={saveChanges} cStyle="green" className={styles.button}>
{t('configure.save_changes_dialog.save_settings')}
</Button>
</div>
</Dialog>
);
SaveChangesDialog.propTypes = {
saveDialog: PropTypes.bool.isRequired,
hideSaveDialog: PropTypes.func.isRequired,
saveChanges: PropTypes.func.isRequired,
discardChanges: PropTypes.func.isRequired,
};
export default SaveChangesDialog;
@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
@@ -10,15 +10,70 @@ import { getDefinitionName } from 'coral-framework/utils';
import StreamSettings from './StreamSettings';
import TechSettings from './TechSettings';
import ModerationSettings from './ModerationSettings';
import { clearPending, setActiveSection } from '../../../actions/configure';
import {
clearPending,
showSaveDialog,
hideSaveDialog,
} from '../../../actions/configure';
import Configure from '../components/Configure';
import { withRouter } from 'react-router';
class ConfigureContainer extends React.Component {
state = { nextRoute: '' };
class ConfigureContainer extends Component {
savePending = async () => {
await this.props.updateSettings(this.props.pending);
this.props.clearPending();
};
saveChanges = async () => {
await this.savePending();
this.props.hideSaveDialog();
this.gotoNextRoute();
};
discardChanges = async () => {
await this.props.clearPending();
this.props.hideSaveDialog();
this.gotoNextRoute();
};
gotoNextRoute = () => {
const { nextRoute } = this.state;
if (nextRoute) {
this.props.router.push(nextRoute);
this.setState({ nextRoute: '' });
}
};
handleSectionChange = async section => {
const nextRoute = `/admin/configure/${section}`;
if (this.shouldShowSaveDialog()) {
await this.setState({ nextRoute });
this.props.showSaveDialog();
} else {
// Just go to the section
this.props.router.push(nextRoute);
}
};
shouldShowSaveDialog = () => {
return !!Object.keys(this.props.pending).length;
};
routeLeave = ({ pathname }) => {
if (this.shouldShowSaveDialog()) {
this.setState({ nextRoute: pathname });
this.props.showSaveDialog();
return false;
}
};
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave);
}
render() {
if (this.props.data.error) {
return <div>{this.props.data.error.message}</div>;
@@ -30,14 +85,20 @@ class ConfigureContainer extends Component {
return (
<Configure
saveChanges={this.saveChanges}
discardChanges={this.discardChanges}
saveDialog={this.props.saveDialog}
activeSection={this.props.routes[3].path}
hideSaveDialog={this.props.hideSaveDialog}
canSave={this.props.canSave}
currentUser={this.props.currentUser}
root={this.props.root}
settings={this.props.mergedSettings}
canSave={this.props.canSave}
handleSectionChange={this.handleSectionChange}
savePending={this.savePending}
setActiveSection={this.props.setActiveSection}
activeSection={this.props.activeSection}
/>
>
{this.props.children}
</Configure>
);
}
}
@@ -74,18 +135,21 @@ const mapStateToProps = state => ({
pending: state.configure.pending,
canSave: state.configure.canSave,
activeSection: state.configure.activeSection,
saveDialog: state.configure.saveDialog,
});
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
clearPending,
setActiveSection,
showSaveDialog,
hideSaveDialog,
},
dispatch
);
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
withUpdateSettings,
withConfigureQuery,
@@ -93,14 +157,20 @@ export default compose(
)(ConfigureContainer);
ConfigureContainer.propTypes = {
activeSection: PropTypes.string,
updateSettings: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
setActiveSection: PropTypes.func.isRequired,
showSaveDialog: PropTypes.func.isRequired,
hideSaveDialog: PropTypes.func.isRequired,
saveDialog: PropTypes.bool.isRequired,
currentUser: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
pending: PropTypes.object.isRequired,
mergedSettings: PropTypes.object.isRequired,
activeSection: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
router: PropTypes.object,
route: PropTypes.object,
routes: PropTypes.array,
};
@@ -10,12 +10,14 @@ const InitialStep = () => {
return (
<div className={cn(styles.step, styles.finalStep, 'talk-install-step-5')}>
<p>{t('install.final.description')}</p>
<Button raised>
<Link to="/admin">{t('install.final.launch')}</Link>
</Button>
<Button cStyle="black" raised>
<a href="http://coralproject.net">{t('install.final.close')}</a>
</Button>
<Link to="/admin">
<Button raised>{t('install.final.launch')}</Button>
</Link>
<a href="http://coralproject.net">
<Button cStyle="black" raised>
{t('install.final.close')}
</Button>
</a>
</div>
);
};
-8
View File
@@ -1,8 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import { GraphQLDocs } from 'graphql-docs';
import fetcher from './services/fetcher';
// Render the application into the DOM
render(<GraphQLDocs fetcher={fetcher} />, document.querySelector('#root'));
-10
View File
@@ -1,10 +0,0 @@
export default function fetcher(query) {
return fetch(`${window.location.origin}/api/v1/graph/ql`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
}).then(res => res.json());
}
@@ -24,14 +24,20 @@ export default class Embed extends React.Component {
>
{t('embed_comments_tab')}
</Tab>,
<Tab
key="profile"
tabId="profile"
className="talk-embed-stream-profile-tab"
>
{t('framework.my_profile')}
</Tab>,
];
if (this.props.currentUser) {
tabs.push(
<Tab
key="profile"
tabId="profile"
className="talk-embed-stream-profile-tab"
>
{t('framework.my_profile')}
</Tab>
);
}
if (can(this.props.currentUser, 'UPDATE_ASSET_CONFIG')) {
tabs.push(
<Tab
@@ -43,6 +49,7 @@ export default class Embed extends React.Component {
</Tab>
);
}
return tabs;
}
@@ -16,9 +16,11 @@ class ExtendableTabPanel extends React.Component {
} = this.props;
return (
<div {...rest}>
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
{tabs && (
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
)}
{loading ? (
<div className={styles.spinnerContainer}>
<Spinner />
@@ -35,12 +35,9 @@ class ExtendableTabPanelContainer extends React.Component {
createPluginTabFactory = (props = this.props) => el => {
return (
<ExtendableTab
tabId={el.type.talkPluginName}
key={el.type.talkPluginName}
>
<ExtendableTab tabId={el.key} key={el.key}>
{React.cloneElement(el, {
active: props.activeTab === el.type.talkPluginName,
active: props.activeTab === el.key,
})}
</ExtendableTab>
);
@@ -59,7 +56,7 @@ class ExtendableTabPanelContainer extends React.Component {
createPluginTabPane(el) {
return (
<TabPane tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
<TabPane tabId={el.key} key={el.key}>
{el}
</TabPane>
);
@@ -34,6 +34,7 @@ class Comment extends React.Component {
defaultComponent={CommentContent}
className={cn(styles.commentBody, 'my-comment-body')}
passthrough={slotPassthrough}
size={1}
/>
<div className={cn(styles.commentSummary, 'comment-summary')}>
<span
@@ -1,14 +0,0 @@
.message {
padding: 10px 0 20px;
letter-spacing: 0.1px;
font-size: 13px;
line-height: 33px;
}
.message a {
color: black;
font-weight: bold;
cursor: pointer;
margin: 0px;
padding-bottom: 2px;
}
@@ -1,15 +0,0 @@
import React from 'react';
import styles from './NotLoggedIn.css';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
export default ({ showSignInDialog }) => (
<div className={cn(styles.message, 'talk-embed-stream-not-logged-in')}>
<div>
<a onClick={showSignInDialog}>{t('settings.sign_in')}</a>{' '}
{t('settings.to_access')}
</div>
<div>{t('from_settings_page')}</div>
</div>
);
@@ -2,27 +2,17 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { withQuery } from 'coral-framework/hocs';
import NotLoggedIn from '../components/NotLoggedIn';
import { Spinner } from 'coral-ui';
import Profile from '../components/Profile';
import TabPanel from './TabPanel';
import { getDefinitionName } from 'coral-framework/utils';
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
class ProfileContainer extends Component {
componentWillReceiveProps(nextProps) {
if (!this.props.currentUser && nextProps.currentUser) {
// Refetch after login.
this.props.data.refetch();
}
}
render() {
const { currentUser, showSignInDialog, root } = this.props;
const { currentUser, root } = this.props;
const { me } = this.props.root;
const loading = this.props.data.loading;
@@ -30,10 +20,6 @@ class ProfileContainer extends Component {
return <div>{this.props.data.error.message}</div>;
}
if (!currentUser) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (loading || !me) {
return <Spinner />;
}
@@ -57,7 +43,6 @@ ProfileContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
currentUser: PropTypes.object,
showSignInDialog: PropTypes.func,
};
const slots = ['profileSections'];
@@ -85,10 +70,6 @@ const mapStateToProps = state => ({
currentUser: state.auth.user,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({ showSignInDialog }, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withProfileQuery
)(ProfileContainer);
export default compose(connect(mapStateToProps), withProfileQuery)(
ProfileContainer
);
@@ -18,6 +18,10 @@ const InactiveCommentLabel = ({ status, className, ...rest }) => {
label = t('modqueue.rejected');
icon = 'close';
break;
case 'SYSTEM_WITHHELD':
label = t('modqueue.system_withheld');
icon = 'flag';
break;
default:
throw new Error(`Unknown inactive status ${status}`);
}
+12 -15
View File
@@ -15,9 +15,7 @@ export const checkLogin = () => (
rest('/auth')
.then(result => {
if (!result.user) {
if (localStorage) {
cleanAuthData(localStorage);
}
cleanAuthData(localStorage);
dispatch(checkLoginSuccess(null));
return;
}
@@ -52,10 +50,12 @@ const checkLoginSuccess = user => ({
});
export const setAuthToken = token => (dispatch, _, { localStorage }) => {
if (localStorage) {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
}
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
// Dispatch the set auth token action. For some browsers and situations, we
// may not be able to persist the auth token any other way. Keep it in redux!
dispatch({ type: actions.SET_AUTH_TOKEN, token });
dispatch(checkLogin());
};
@@ -66,11 +66,8 @@ export const handleSuccessfulLogin = (user, token) => (
{ client, localStorage, postMessage }
) => {
const { exp } = jwtDecode(token);
if (localStorage) {
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
}
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
// Send the message via the messages service to the window.opener if it
// exists.
@@ -87,6 +84,7 @@ export const handleSuccessfulLogin = (user, token) => (
dispatch({
type: actions.HANDLE_SUCCESSFUL_LOGIN,
user,
token,
});
};
@@ -100,9 +98,8 @@ export const logout = () => async (
) => {
await rest('/auth', { method: 'DELETE' });
if (localStorage) {
cleanAuthData(localStorage);
}
// Clear the auth data persisted to localStorage.
cleanAuthData(localStorage);
// Reset the websocket.
client.resetWebsocket();
@@ -7,7 +7,7 @@ class IfSlotIsNotEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const { slotElements } = props;
return slotElements.length === 0
? false
? true
: slotElements.every(elements => elements.length === 0);
}
@@ -19,6 +19,7 @@ class IfSlotIsNotEmpty extends React.Component {
IfSlotIsNotEmpty.propTypes = {
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
slotElements: PropTypes.array.isRequired,
children: PropTypes.node.isRequired,
passthrough: PropTypes.object.isRequired,
};
+2
View File
@@ -1,5 +1,7 @@
const prefix = `TALK_FRAMEWORK`;
export const SET_AUTH_TOKEN = `${prefix}_SET_AUTH_TOKEN`;
export const CHECK_LOGIN_REQUEST = `${prefix}_CHECK_LOGIN_REQUEST`;
export const CHECK_LOGIN_SUCCESS = `${prefix}_CHECK_LOGIN_SUCCESS`;
export const CHECK_LOGIN_FAILURE = `${prefix}_CHECK_LOGIN_FAILURE`;
+34 -1
View File
@@ -1,5 +1,6 @@
import { gql } from 'react-apollo';
import withMutation from '../hocs/withMutation';
import update from 'immutability-helper';
function convertItemType(item_type) {
switch (item_type) {
@@ -167,9 +168,39 @@ export const withSetCommentStatus = withMutation(
errors: null,
},
},
updateQueries: {
CoralAdmin_UserDetail: prev => {
const increment = {
rejectedComments: {
$apply: count =>
count < prev.totalComments ? count + 1 : count,
},
};
const decrement = {
rejectedComments: {
$apply: count => (count > 0 ? count - 1 : 0),
},
};
// If rejected then increment rejectedComments by one
if (status === 'REJECTED') {
const updated = update(prev, increment);
return updated;
}
// If approved then decrement rejectedComments by one
if (status === 'ACCEPTED') {
const updated = update(prev, decrement);
return updated;
}
return prev;
},
},
update: proxy => {
const fragment = gql`
fragment Talk_SetCommentStatus on Comment {
fragment Talk_SetCommentStatus_Comment on Comment {
status
status_history {
type
@@ -182,9 +213,11 @@ export const withSetCommentStatus = withMutation(
const data = proxy.readFragment({ fragment, id: fragmentId });
data.status = status;
data.status_history = data.status_history
? data.status_history
: [];
data.status_history.push({
__typename: 'CommentStatusHistory',
type: status,
+9
View File
@@ -5,6 +5,7 @@ const initialState = {
checkedInitialLogin: false,
initialLoginError: null,
user: null,
token: null,
};
const purge = user => {
@@ -14,12 +15,18 @@ const purge = user => {
export default function auth(state = initialState, action) {
switch (action.type) {
case actions.SET_AUTH_TOKEN:
return {
...state,
token: action.token || null,
};
case actions.CHECK_LOGIN_FAILURE:
return {
...state,
initialLoginError: action.error,
checkedInitialLogin: true,
user: null,
token: null,
};
case actions.CHECK_LOGIN_SUCCESS:
return {
@@ -31,11 +38,13 @@ export default function auth(state = initialState, action) {
return {
...state,
user: action.user ? purge(action.user) : null,
token: action.token || null,
};
case actions.LOGOUT:
return {
...state,
user: null,
token: null,
};
case actions.UPDATE_STATUS: {
return {
+7 -1
View File
@@ -47,6 +47,12 @@ const getAuthToken = (store, storage) => {
} else if (!bowser.safari && !bowser.ios && storage) {
// Use local storage auth tokens where there's a stable api.
return storage.getItem('token');
} else if (state.auth && state.auth.token) {
// Use the redux token state if the remaining methods fall out. If the embed
// is called with `embed.login(token)`, and the browser is not capable of
// storing the token in localStorage, then we would have persisted it to the
// redux state.
return state.auth.token;
}
return null;
@@ -123,7 +129,7 @@ export async function createContext({
// Try to get the token from localStorage. If it isn't here, it may
// be passed as a cookie.
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERENT
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return getAuthToken(store, localStorage);
};
+31 -15
View File
@@ -52,26 +52,41 @@ let lang;
let timeagoInstance;
function setLocale(storage, locale) {
try {
if (storage) {
storage.setItem('locale', locale);
}
} catch (err) {
console.error(err);
}
storage.setItem('locale', locale);
}
function getLocale(storage) {
// detectLanguage will try to get the locale from storage if available,
// otherwise will try to get it from the navigator, otherwise, it will fallback
// to the default language.
function detectLanguage(storage) {
try {
return (
(storage && storage.getItem('locale')) ||
navigator.language ||
defaultLanguage
).split('-')[0];
const lang = storage.getItem('locale') || navigator.language;
if (lang) {
return lang;
}
} catch (err) {
console.error(err);
return null;
console.warn(
'Error while trying to detect language, will fallback to',
err
);
}
console.warn('Could not detect language, will fallback to', defaultLanguage);
return defaultLanguage;
}
// getLocale will get the users locale from the local detector and parse it to a
// format we can work with.
function getLocale(storage) {
// Get the language from the local detector.
const lang = detectLanguage(storage);
// Some language strings come with additional subtags as defined in:
//
// https://www.ietf.org/rfc/bcp/bcp47.txt
//
// So we should strip that off if we find it.
return lang.split('-')[0];
}
export function setupTranslations() {
@@ -136,6 +151,7 @@ export function t(key, ...replacements) {
replacements.forEach((str, i) => {
translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str);
});
return translation;
} else {
console.warn(`${lang}.${key} and en.${key} language key not set`);
+21 -2
View File
@@ -173,11 +173,30 @@ class PluginsService {
);
}
/**
* This adds a consistent keying for the slot elements.
* It uses the plugin name as the key. If the same plugin inserts
* multiple elements it will append `.${noOfOccurence}` to the
* key starting with the second element.
*/
const getKey = (() => {
const map = {};
return component => {
if (map[component.talkPluginName] === undefined) {
map[component.talkPluginName] = 0;
} else {
map[component.talkPluginName]++;
}
const i = map[component.talkPluginName];
return `${component.talkPluginName}${i > 0 ? `.${i}` : ''}`;
};
})();
return (size > 0 ? slots.slice(0, size) : slots)
.map((component, i) => ({
.map(component => ({
component,
disabled: isDisabled(component),
key: i,
key: getKey(component),
}))
.filter(o => !o.disabled)
.map(({ component, key }) =>
+104 -28
View File
@@ -1,40 +1,116 @@
import uuid from 'uuid/v4';
function getStorage(type) {
let storage;
try {
storage = window[type];
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
} catch (e) {
const ignore =
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// SecurityError related to having 3rd party cookies disabled.
e.code === 18 ||
// Firefox
function testStorageAccess(storage) {
const key = '__storage_test__';
e.code === 1014 ||
// test name field too, because code might not be present
// Create a unique test value.
const expectedValue = String(Date.now());
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (!ignore) {
console.warn(e);
// Try to set, get, and remove that item.
storage.setItem(key, expectedValue);
const canSetGet = expectedValue === storage.getItem(key);
storage.removeItem(key);
if (!canSetGet) {
// We can't access the desired storage!
throw new Error('Storage access test failed');
}
}
// InMemoryStorage is a dumb implementation of the Storage interface that will
// not persist the data at all. It implements the Storage interface found:
//
// https://developer.mozilla.org/en-US/docs/Web/API/Storage
//
class InMemoryStorage {
constructor() {
this.storage = {};
}
get length() {
return Object.keys(this.storage).length;
}
key(n) {
if (this.length <= n) {
return undefined;
}
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
return getStorage('sessionStorage');
return this.storage[Object.keys(this.storage)[n]];
}
getItem(key) {
return this.storage[key];
}
setItem(key, value) {
this.storage[key] = value;
try {
// Test sessionStorage. We could have been given access recently.
testStorageAccess(sessionStorage);
// Test passed! Set the item in sessionStorage.
sessionStorage.setItem(key, value);
console.log(
'Attempt to persist InMemoryStorage value to sessionStorage succeeded'
);
} catch (err) {
console.warn(
'Attempt to persist InMemoryStorage value to sessionStorage failed',
err
);
}
}
return storage;
removeItem(key) {
delete this.storage[key];
try {
// Test sessionStorage. We could have been given access recently.
testStorageAccess(sessionStorage);
// Test passed! Remove the item from sessionStorage.
sessionStorage.removeItem(key);
console.log(
'Attempt to persist InMemoryStorage delete to sessionStorage succeeded'
);
} catch (err) {
console.warn(
'Attempt to persist InMemoryStorage delete to sessionStorage failed',
err
);
}
}
}
// getStorage will test to see if the requested storage type is available, if it
// is not, it will try sessionStorage, and if that is also not available, it
// will fallback to InMemoryStorage.
function getStorage(type) {
try {
// Get the desired storage from the window and test it out.
const storage = window[type];
testStorageAccess(storage);
// Storage test was successful! Return it.
return storage;
} catch (err) {
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
console.warn('Could not access', type, 'trying sessionStorage', err);
return getStorage('sessionStorage');
}
console.warn(
'Could not access sessionStorage falling back to InMemoryStorage',
err
);
}
// No acceptable storage could be found, returning the InMemoryStorage.
return new InMemoryStorage();
}
/**
+3
View File
@@ -27,11 +27,14 @@ const TextField = ({
);
TextField.propTypes = {
id: PropTypes.string,
label: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
errorMsg: PropTypes.string,
type: PropTypes.string,
className: PropTypes.string,
showErrors: PropTypes.bool,
};
export default TextField;
+36
View File
@@ -0,0 +1,36 @@
const { pluginsPath } = require('../plugins');
const buildTargets = ['coral-admin'];
const buildEmbeds = ['stream'];
const specPattern = 'client/**/__tests__/**/*.spec.js?(x)';
module.exports = {
rootDir: '../',
testMatch: [
`<rootDir>/${specPattern}`,
`<rootDir>/plugins/**/${specPattern}`,
],
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
modulePaths: [
'<rootDir>/plugins',
'<rootDir>/client',
...buildTargets.map(target => `<rootDir>/client/${target}/src`),
...buildEmbeds.map(embed => `<rootDir>/client/coral-embed-${embed}/src`),
],
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
moduleDirectories: ['node_modules'],
transform: {
'^.+\\.jsx?$': 'babel-jest',
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
'^pluginsConfig$': pluginsPath,
'\\.(scss|css|less)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
},
};