Merge master

This commit is contained in:
Mendel Konikov
2018-05-01 20:25:05 -04:00
225 changed files with 5840 additions and 2389 deletions
+2
View File
@@ -10,6 +10,7 @@ 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 OrganizationSettings from './routes/Configure/containers/OrganizationSettings';
import { ModerationLayout, Moderation } from 'routes/Moderation';
@@ -25,6 +26,7 @@ const routes = (
<Route path="stream" component={StreamSettings} />
<Route path="moderation" component={ModerationSettings} />
<Route path="tech" component={TechSettings} />
<Route path="organization" component={OrganizationSettings} />
<IndexRedirect to="stream" />
</Route>
+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;
@@ -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());
}
}
@@ -5,6 +5,7 @@ const initialState = {
isLoading: false,
data: {
settings: {
organizationContactEmail: '',
organizationName: '',
domains: {
whitelist: [],
@@ -19,6 +20,7 @@ const initialState = {
},
errors: {
organizationName: '',
organizationContactEmail: '',
username: '',
email: '',
password: '',
@@ -8,7 +8,14 @@ import SaveChangesDialog from './SaveChangesDialog';
class Configure extends React.Component {
render() {
const { canSave, currentUser, root, savePending, settings } = this.props;
const {
canSave,
currentUser,
root,
savePending,
settings,
clearPending,
} = this.props;
if (!can(currentUser, 'UPDATE_CONFIG')) {
return <p>{t('configure.access_message')}</p>;
@@ -17,6 +24,9 @@ class Configure extends React.Component {
const passProps = {
root,
settings,
savePending,
clearPending,
canSave,
};
return (
@@ -41,6 +51,9 @@ class Configure extends React.Component {
<Item itemId="tech" icon="code">
{t('configure.tech_settings')}
</Item>
<Item itemId="organization" icon="people">
{t('configure.organization_information')}
</Item>
</List>
<div className={styles.saveBox}>
{canSave ? (
@@ -81,6 +94,7 @@ Configure.propTypes = {
children: PropTypes.node.isRequired,
saveDialog: PropTypes.bool,
hideSaveDialog: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
};
export default Configure;
@@ -0,0 +1,87 @@
.label {
display: block;
}
.detailList {
padding: 0;
margin: 0;
}
.detailLabel {
color: #000;
font-size: 1.1em;
font-weight: bold;
display: block;
margin-bottom: 4px;
}
.detailValue {
padding: 6px 0;
border: solid 1px transparent;
display: block;
font-size: 1.1em;
border-radius: 2px;
color: #424242;
box-sizing: border-box;
min-width: 300px;
}
.editable {
padding-left: 10px;
padding-right: 10px;
border-color: grey;
}
.detailItem {
margin-bottom: 16px;
list-style: none;
}
.button, .button:disabled {
background: white;
border: solid 1px grey;
}
.actionBox {
flex-grow: 0;
}
.cancelButton {
padding: 10px;
display: block;
color: #4f5c67;
font-weight: 500;
&:hover {
cursor: pointer;
}
}
.changedSave {
background-color: #00796B;
color: white;
}
.errorList {
list-style: none;
padding: 0;
margin: 0;
}
.errorItem {
padding: 5px 10px;
margin-bottom: 20px;
color: #b71c1c;
border-radius: 2px;
display: inline-block;
background: #F9D3CE;
}
.container {
display: flex;
}
.content {
flex-grow: 1;
padding-right: 40px;
}
@@ -0,0 +1,185 @@
import React from 'react';
import cn from 'classnames';
import { Button } from 'coral-ui';
import PropTypes from 'prop-types';
import styles from './OrganizationSettings.css';
import Slot from 'coral-framework/components/Slot';
import t from 'coral-framework/services/i18n';
import ConfigurePage from './ConfigurePage';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
class OrganizationSettings extends React.Component {
state = { editing: false, errors: [] };
addError = err => {
if (this.state.errors.indexOf(err) === -1) {
this.setState(({ errors }) => ({
errors: errors.concat(err),
}));
}
};
removeError = err => {
this.setState(({ errors }) => ({
errors: errors.filter(i => i !== err),
}));
};
toggleEditing = () => {
this.setState(({ editing }) => ({
editing: !editing,
}));
};
disableEditing = () => {
this.setState(() => ({
editing: false,
}));
};
updateName = event => {
const updater = { organizationName: { $set: event.target.value } };
this.props.updatePending({ updater });
};
updateEmail = event => {
let error = null;
const email = event.target.value;
// Add a blocker error
if (!validate.email(email)) {
error = true;
this.addError('email');
} else {
this.removeError('email');
}
const updater = { organizationContactEmail: { $set: email } };
const errorUpdater = { organizationEmail: { $set: error } };
this.props.updatePending({ updater, errorUpdater });
};
cancelEditing = () => {
this.disableEditing();
this.props.clearPending();
};
save = async () => {
await this.props.savePending();
this.disableEditing();
};
displayErrors = (errors = []) => (
<ul className={styles.errorList}>
{errors.map((errKey, i) => (
<li key={`${i}_${errKey}`} className={styles.errorItem}>
{errorMsj[errKey]}
</li>
))}
</ul>
);
render() {
const { settings, slotPassthrough, canSave } = this.props;
const hasErrors = this.state.errors.length;
return (
<ConfigurePage title={t('configure.organization_information')}>
<p>{t('configure.organization_info_copy')}</p>
<p>{t('configure.organization_info_copy_2')}</p>
<ConfigureCard>
<div className={styles.container}>
<div className={styles.content}>
{this.displayErrors(this.state.errors)}
<ul className={styles.detailList}>
<li className={styles.detailItem}>
<label
className={styles.detailLabel}
id={t('configure.organization_name')}
>
{t('configure.organization_name')}
</label>
<input
type="text"
className={cn(styles.detailValue, {
[styles.editable]: this.state.editing,
})}
onChange={this.updateName}
value={settings.organizationName}
id={t('configure.organization_name')}
readOnly={!this.state.editing}
/>
</li>
<li className={styles.detailItem}>
<label
className={styles.detailLabel}
id={t('configure.organization_contact_email')}
>
{t('configure.organization_contact_email')}
</label>
<input
type="text"
className={cn(styles.detailValue, {
[styles.editable]: this.state.editing,
})}
onChange={this.updateEmail}
value={settings.organizationContactEmail || ''}
id={t('configure.organization_contact_email')}
readOnly={!this.state.editing}
/>
</li>
</ul>
</div>
{!this.state.editing ? (
<div className={styles.actionBox}>
<Button
className={styles.button}
icon="settings"
onClick={this.toggleEditing}
full
>
{t('configure.edit_info')}
</Button>
</div>
) : (
<div className={styles.actionBox}>
{canSave && !hasErrors ? (
<Button
raised
onClick={this.save}
className={styles.changedSave}
icon="check"
full
>
{t('configure.save')}
</Button>
) : (
<Button className={styles.button} disabled icon="check" full>
{t('configure.save')}
</Button>
)}
<a className={styles.cancelButton} onClick={this.cancelEditing}>
{t('cancel')}
</a>
</div>
)}
</div>
</ConfigureCard>
<Slot fill="adminOrganizationSettings" passthrough={slotPassthrough} />
</ConfigurePage>
);
}
}
OrganizationSettings.propTypes = {
savePending: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
slotPassthrough: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
};
export default OrganizationSettings;
@@ -16,10 +16,12 @@ import {
hideSaveDialog,
} from '../../../actions/configure';
import Configure from '../components/Configure';
import OrganizationSettings from './OrganizationSettings';
import { withRouter } from 'react-router';
class ConfigureContainer extends React.Component {
state = { nextRoute: '' };
nextRoute = '';
unregisterLeaveHook = null;
savePending = async () => {
await this.props.updateSettings(this.props.pending);
@@ -39,18 +41,16 @@ class ConfigureContainer extends React.Component {
};
gotoNextRoute = () => {
const { nextRoute } = this.state;
if (nextRoute) {
this.props.router.push(nextRoute);
this.setState({ nextRoute: '' });
if (this.nextRoute) {
this.props.router.push(this.nextRoute);
this.nextRoute = '';
}
};
handleSectionChange = async section => {
const nextRoute = `/admin/configure/${section}`;
if (this.shouldShowSaveDialog()) {
await this.setState({ nextRoute });
if (this.hasPendingData()) {
this.nextRoute = nextRoute;
this.props.showSaveDialog();
} else {
// Just go to the section
@@ -58,20 +58,37 @@ class ConfigureContainer extends React.Component {
}
};
shouldShowSaveDialog = () => {
navigationPrompt = e => {
if (this.hasPendingData()) {
const confirmationMessage = 'Changes that you made may not be saved.';
e.returnValue = confirmationMessage; // Gecko, Trident, Chrome 34+
return confirmationMessage; // Gecko, WebKit, Chrome <34
}
};
hasPendingData = () => {
return !!Object.keys(this.props.pending).length;
};
routeLeave = ({ pathname }) => {
if (this.shouldShowSaveDialog()) {
this.setState({ nextRoute: pathname });
if (this.hasPendingData()) {
this.nextRoute = pathname;
this.props.showSaveDialog();
return false;
}
};
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave);
this.unregisterLeaveHook = this.props.router.setRouteLeaveHook(
this.props.route,
this.routeLeave
);
window.addEventListener('beforeunload', this.navigationPrompt);
}
componentWillUnmount() {
this.unregisterLeaveHook();
window.removeEventListener('beforeunload', this.navigationPrompt);
}
render() {
@@ -83,18 +100,21 @@ class ConfigureContainer extends React.Component {
return <Spinner />;
}
const activeSection = this.props.routes[3].path;
return (
<Configure
saveChanges={this.saveChanges}
discardChanges={this.discardChanges}
saveDialog={this.props.saveDialog}
activeSection={this.props.routes[3].path}
activeSection={activeSection}
hideSaveDialog={this.props.hideSaveDialog}
canSave={this.props.canSave}
currentUser={this.props.currentUser}
root={this.props.root}
settings={this.props.mergedSettings}
handleSectionChange={this.handleSectionChange}
clearPending={this.props.clearPending}
savePending={this.savePending}
>
{this.props.children}
@@ -110,10 +130,12 @@ const withConfigureQuery = withQuery(
...${getDefinitionName(StreamSettings.fragments.settings)}
...${getDefinitionName(TechSettings.fragments.settings)}
...${getDefinitionName(ModerationSettings.fragments.settings)}
...${getDefinitionName(OrganizationSettings.fragments.settings)}
}
...${getDefinitionName(StreamSettings.fragments.root)}
...${getDefinitionName(TechSettings.fragments.root)}
...${getDefinitionName(ModerationSettings.fragments.root)}
...${getDefinitionName(OrganizationSettings.fragments.root)}
}
${StreamSettings.fragments.root}
${StreamSettings.fragments.settings}
@@ -121,6 +143,8 @@ const withConfigureQuery = withQuery(
${TechSettings.fragments.settings}
${ModerationSettings.fragments.root}
${ModerationSettings.fragments.settings}
${OrganizationSettings.fragments.root}
${OrganizationSettings.fragments.settings}
`,
{
options: () => ({
@@ -0,0 +1,53 @@
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
import OrganizationSettings from '../components/OrganizationSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import { updatePending } from '../../../actions/configure';
import { mapProps } from 'recompose';
const slots = ['adminOrganizationSettings'];
const mapStateToProps = state => ({
errors: state.configure.errors,
});
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
updatePending,
},
dispatch
);
export default compose(
withFragments({
root: gql`
fragment TalkAdmin_OrganizationSettings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
settings: gql`
fragment TalkAdmin_OrganizationSettings_settings on Settings {
organizationName
organizationContactEmail
${getSlotFragmentSpreads(slots, 'settings')}
}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
settings,
updatePending,
errors,
},
updatePending,
settings,
errors,
...rest,
}))
)(OrganizationSettings);
@@ -1,15 +1,16 @@
import React, { Component } from 'react';
import React from 'react';
import styles from './Install.css';
import { Wizard, WizardNav } from 'coral-ui';
import Layout from 'coral-admin/src/components/Layout';
import PropTypes from 'prop-types';
import InitialStep from './Steps/InitialStep';
import AddOrganizationName from './Steps/AddOrganizationName';
import OrganizationDetails from './Steps/OrganizationDetails';
import CreateYourAccount from './Steps/CreateYourAccount';
import PermittedDomainsStep from './Steps/PermittedDomainsStep';
import FinalStep from './Steps/FinalStep';
export default class Install extends Component {
class Install extends React.Component {
handleDomainsChange = value => {
this.props.updatePermittedDomains(value);
};
@@ -55,7 +56,7 @@ export default class Install extends Component {
goToStep={this.props.goToStep}
>
<InitialStep />
<AddOrganizationName
<OrganizationDetails
install={install}
handleSettingsChange={this.handleSettingsChange}
handleSettingsSubmit={this.handleSettingsSubmit}
@@ -81,3 +82,18 @@ export default class Install extends Component {
);
}
}
Install.propTypes = {
updatePermittedDomains: PropTypes.func.isRequired,
updateSettingsFormData: PropTypes.func.isRequired,
updateUserFormData: PropTypes.func.isRequired,
submitSettings: PropTypes.func.isRequired,
submitUser: PropTypes.func.isRequired,
install: PropTypes.object.isRequired,
nextStep: PropTypes.func.isRequired,
previousStep: PropTypes.func.isRequired,
goToStep: PropTypes.func.isRequired,
finishInstall: PropTypes.func.isRequired,
};
export default Install;
@@ -21,6 +21,17 @@ const AddOrganizationName = props => {
showErrors={install.showErrors}
errorMsg={install.errors.organizationName}
/>
<TextField
className={styles.TextField}
id="organizationContactEmail"
type="email"
label={t('install.create.organization_contact_email')}
onChange={handleSettingsChange}
showErrors={install.showErrors}
errorMsg={install.errors.organizationContactEmail}
/>
<Button
className="talk-install-step-2-save-button"
type="submit"
@@ -1,8 +1,7 @@
import React, { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { compose } from 'react-apollo';
import Install from '../components/Install';
import {
@@ -18,7 +17,7 @@ import {
updatePermittedDomains,
} from '../../../actions/install';
class InstallContainer extends Component {
class InstallContainer extends React.Component {
componentDidMount() {
const { checkInstall } = this.props;
checkInstall(() => {
@@ -27,7 +26,21 @@ class InstallContainer extends Component {
}
render() {
return <Install {...this.props} />;
return (
<Install
install={this.props.install}
goToStep={this.props.goToStep}
nextStep={this.props.nextStep}
submitUser={this.props.submitUser}
checkInstall={this.props.checkInstall}
previousStep={this.props.previousStep}
finishInstall={this.props.finishInstall}
submitSettings={this.props.submitSettings}
updateUserFormData={this.props.updateUserFormData}
updateSettingsFormData={this.props.updateSettingsFormData}
updatePermittedDomains={this.props.updatePermittedDomains}
/>
);
}
}
@@ -35,6 +48,20 @@ InstallContainer.contextTypes = {
router: PropTypes.object,
};
InstallContainer.propTypes = {
install: PropTypes.object.isRequired,
goToStep: PropTypes.func.isRequired,
nextStep: PropTypes.func.isRequired,
submitUser: PropTypes.func.isRequired,
checkInstall: PropTypes.func.isRequired,
previousStep: PropTypes.func.isRequired,
finishInstall: PropTypes.func.isRequired,
submitSettings: PropTypes.func.isRequired,
updateUserFormData: PropTypes.func.isRequired,
updateSettingsFormData: PropTypes.func.isRequired,
updatePermittedDomains: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
install: state.install,
});
@@ -56,6 +83,4 @@ const mapDispatchToProps = dispatch =>
dispatch
);
export default compose(connect(mapStateToProps, mapDispatchToProps))(
InstallContainer
);
export default connect(mapStateToProps, mapDispatchToProps)(InstallContainer);
-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 />
@@ -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>
);
@@ -4,13 +4,32 @@ import Slot from 'coral-framework/components/Slot';
import styles from './Profile.css';
import TabPanel from '../containers/TabPanel';
const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
const DefaultProfileHeader = ({ username, emailAddress }) => (
<div className={styles.userInfo}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
</div>
);
DefaultProfileHeader.propTypes = {
username: PropTypes.string,
emailAddress: PropTypes.string,
};
const Profile = ({ id, username, emailAddress, root, slotPassthrough }) => {
return (
<div className="talk-my-profile talk-profile-container">
<div className={styles.userInfo}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
</div>
<Slot
fill="profileHeader"
size={1}
defaultComponent={DefaultProfileHeader}
passthrough={{
...slotPassthrough,
id,
username,
emailAddress,
}}
/>
<Slot fill="profileSections" passthrough={slotPassthrough} />
<TabPanel root={root} slotPassthrough={slotPassthrough} />
</div>
@@ -18,6 +37,7 @@ const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
};
Profile.propTypes = {
id: PropTypes.string,
username: PropTypes.string,
emailAddress: PropTypes.string,
root: PropTypes.object,
@@ -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 />;
}
@@ -44,6 +30,7 @@ class ProfileContainer extends Component {
return (
<Profile
id={me.id}
username={me.username}
emailAddress={emailAddress}
root={root}
@@ -57,7 +44,6 @@ ProfileContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
currentUser: PropTypes.object,
showSignInDialog: PropTypes.func,
};
const slots = ['profileSections'];
@@ -68,6 +54,15 @@ const withProfileQuery = withQuery(
me {
id
username
state {
status {
username {
history {
created_at
}
}
}
}
}
...${getDefinitionName(TabPanel.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
@@ -85,10 +80,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
);
@@ -574,7 +574,9 @@ export default class Comment extends React.Component {
'talk-stream-comment-header-tags-container'
)}
>
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
{isStaff(comment.tags) ? (
<TagLabel>{t('community.staff')}</TagLabel>
) : null}
<Slot
className={cn(
@@ -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}`);
}
+2 -2
View File
@@ -177,8 +177,8 @@ button.comment__action-button[disabled],
}
.talk-plugin-flags-popup-header {
font-weight: bolder;
font-size: 1.33rem;
font-weight: bold;
font-size: 1rem;
margin-bottom: 10px;
}
+7 -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,8 @@ 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!
@@ -70,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.
@@ -105,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();
@@ -43,7 +43,7 @@ const ConfigureCard = ({
);
ConfigureCard.propTypes = {
title: PropTypes.string.isRequired,
title: PropTypes.string,
className: PropTypes.string,
onCheckbox: PropTypes.func,
checked: PropTypes.bool,
+2 -1
View File
@@ -25,6 +25,7 @@ export default {
'UnsuspendUserResponse',
'UpdateAssetSettingsResponse',
'UpdateAssetStatusResponse',
'UpdateSettingsResponse'
'UpdateSettingsResponse',
'ChangePasswordResponse'
),
};
+55 -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,
@@ -590,6 +623,27 @@ export const withUpdateSettings = withMutation(
}
);
export const withChangePassword = withMutation(
gql`
mutation ChangePassword($input: ChangePasswordInput!) {
changePassword(input: $input) {
...ChangePasswordResponse
}
}
`,
{
props: ({ mutate }) => ({
changePassword: input => {
return mutate({
variables: {
input,
},
});
},
}),
}
);
export const withUpdateAssetSettings = withMutation(
gql`
mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) {
+1
View File
@@ -6,4 +6,5 @@ export default {
username: t('error.username'),
confirmPassword: t('error.confirm_password'),
organizationName: t('error.organization_name'),
organizationContactEmail: t('error.organization_contact_email'),
};
@@ -4,4 +4,5 @@ export default {
confirmPassword: () => true,
username: username => /^[a-zA-Z0-9_]+$/.test(username),
organizationName: org => /^[a-zA-Z0-9_ ]+$/.test(org),
organizationContactEmail: email => /^.+@.+\..+$/.test(email),
};
+38 -20
View File
@@ -10,9 +10,12 @@ import 'moment/locale/de';
import 'moment/locale/es';
import 'moment/locale/fr';
import 'moment/locale/he';
import 'moment/locale/nl';
import 'moment/locale/pt-br';
import { createStorage } from 'coral-framework/services/storage';
import {
createStorage
} from 'coral-framework/services/storage';
import arTA from 'timeago.js/locales/ar';
import daTA from 'timeago.js/locales/da';
@@ -20,10 +23,10 @@ import deTA from 'timeago.js/locales/de';
import esTA from 'timeago.js/locales/es';
import frTA from 'timeago.js/locales/fr';
import heTA from 'timeago.js/locales/he';
import nlTA from 'timeago.js/locales/nl';
import pt_BRTA from 'timeago.js/locales/pt_BR';
import zh_CNTA from 'timeago.js/locales/zh_CN';
import zh_TWTA from 'timeago.js/locales/zh_TW';
import nl from 'timeago.js/locales/nl';
import ar from '../../../locales/ar.yml';
import en from '../../../locales/en.yml';
@@ -32,10 +35,10 @@ import de from '../../../locales/de.yml';
import es from '../../../locales/es.yml';
import fr from '../../../locales/fr.yml';
import he from '../../../locales/he.yml';
import nl_NL from '../../../locales/nl_NL.yml';
import pt_BR from '../../../locales/pt_BR.yml';
import zh_CN from '../../../locales/zh_CN.yml';
import zh_TW from '../../../locales/zh_TW.yml';
import nl_NL from '../../../locales/nl_NL.yml';
const defaultLanguage = process.env.TALK_DEFAULT_LANG;
const translations = {
@@ -56,26 +59,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() {
@@ -102,10 +120,10 @@ export function setupTranslations() {
ta.register('de', deTA);
ta.register('fr', frTA);
ta.register('he', heTA);
ta.register('nl_NL', nlTA);
ta.register('pt_BR', pt_BRTA);
ta.register('zh_CN', zh_CNTA);
ta.register('zh_TW', zh_TWTA);
ta.register('nl_NL', nl);
timeagoInstance = ta();
}
@@ -152,4 +170,4 @@ export function t(key, ...replacements) {
export default t;
// Setup the translations globally as soon as this module runs.
setupTranslations();
setupTranslations();
+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();
}
/**
+5 -1
View File
@@ -237,7 +237,11 @@ export function getTotalReactionsCount(actionSummaries) {
// Like lodash merge but does not recurse into arrays.
export function mergeExcludingArrays(objValue, srcValue) {
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
if (
typeof srcValue === 'object' &&
!Array.isArray(srcValue) &&
srcValue !== null
) {
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
}
return srcValue;
+16
View File
@@ -1,4 +1,5 @@
import get from 'lodash/get';
import moment from 'moment';
/**
* getReliability
@@ -33,3 +34,18 @@ export const isSuspended = user => {
export const isBanned = user => {
return get(user, 'state.status.banned.status');
};
/**
* canUsernameBeUpdated
* retrieves boolean whether a username can be updated or not
*/
export const canUsernameBeUpdated = status => {
const oldestEditTime = moment()
.subtract(14, 'days')
.toDate();
return !status.username.history.some(({ created_at }) =>
moment(created_at).isAfter(oldestEditTime)
);
};
+1 -1
View File
@@ -1,5 +1,5 @@
.root {
vertical-align: middle;
vertical-align: sub;
font-size: inherit;
}
+1 -1
View File
@@ -9,7 +9,7 @@
box-sizing: border-box;
background: white;
border-radius: 3px;
padding: 20px 10px;
padding: 10px 10px;
z-index: 300;
right: 1%;
}
+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',
},
};