Merge pull request #874 from coralproject/refactor-global-dependencies

Dependency Management
This commit is contained in:
Kim Gardner
2017-08-25 15:41:53 +01:00
committed by GitHub
82 changed files with 1327 additions and 1149 deletions
+10 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
import {history} from 'coral-framework/helpers/router';
import PropTypes from 'prop-types';
import Configure from 'routes/Configure';
import Dashboard from 'routes/Dashboard';
@@ -47,6 +47,14 @@ const routes = (
</div>
);
const AppRouter = () => <Router history={history} routes={routes}/>;
class AppRouter extends React.Component {
static contextTypes = {
history: PropTypes.object,
};
render() {
return <Router history={this.context.history} routes={routes} />;
}
}
export default AppRouter;
+4 -5
View File
@@ -8,7 +8,6 @@ import {
UPDATE_ASSETS
} from '../constants/assets';
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
/**
@@ -17,9 +16,9 @@ import t from 'coral-framework/services/i18n';
// Fetch a page of assets
// Get comments to fill each of the three lists on the mod queue
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => {
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch, _, {rest}) => {
dispatch({type: FETCH_ASSETS_REQUEST});
return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
return rest(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
.then(({result, count}) =>
dispatch({type: FETCH_ASSETS_SUCCESS,
assets: result,
@@ -34,9 +33,9 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte
// Update an asset state
// Get comments to fill each of the three lists on the mod queue
export const updateAssetState = (id, closedAt) => (dispatch) => {
export const updateAssetState = (id, closedAt) => (dispatch, _, {rest}) => {
dispatch({type: UPDATE_ASSET_STATE_REQUEST, id, closedAt});
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
return rest(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
.then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch((error) => {
console.error(error);
+43 -16
View File
@@ -1,16 +1,13 @@
import bowser from 'bowser';
import * as actions from '../constants/auth';
import coralApi from 'coral-framework/helpers/request';
import * as Storage from 'coral-framework/helpers/storage';
import {handleAuthToken} from 'coral-framework/actions/auth';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
import jwtDecode from 'jwt-decode';
//==============================================================================
// SIGN IN
//==============================================================================
export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => {
export const handleLogin = (email, password, recaptchaResponse) => (dispatch, _, {rest, client, storage}) => {
dispatch({type: actions.LOGIN_REQUEST});
const params = {
@@ -27,18 +24,18 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
};
}
return coralApi('/auth/local', params)
return rest('/auth/local', params)
.then(({user, token}) => {
if (!user) {
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
}
return dispatch(checkLoginFailure('not logged in'));
}
dispatch(handleAuthToken(token));
resetWebsocket();
client.resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
@@ -84,11 +81,11 @@ const forgotPasswordFailure = (error) => ({
error,
});
export const requestPasswordReset = (email) => (dispatch) => {
export const requestPasswordReset = (email) => (dispatch, _, {rest}) => {
dispatch(forgotPasswordRequest(email));
const redirectUri = location.href;
return coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}})
return rest('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}})
.then(() => dispatch(forgotPasswordSuccess()))
.catch((error) => {
console.error(error);
@@ -116,18 +113,18 @@ const checkLoginFailure = (error) => ({
error
});
export const checkLogin = () => (dispatch) => {
export const checkLogin = () => (dispatch, _, {rest, client, storage}) => {
dispatch(checkLoginRequest());
return coralApi('/auth')
return rest('/auth')
.then(({user}) => {
if (!user) {
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
}
return dispatch(checkLoginFailure('not logged in'));
}
resetWebsocket();
client.resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
@@ -136,3 +133,33 @@ export const checkLogin = () => (dispatch) => {
dispatch(checkLoginFailure(errorMessage));
});
};
//==============================================================================
// LOGOUT
//==============================================================================
export const logout = () => (dispatch, _, {rest, client, storage}) => {
return rest('/auth', {method: 'DELETE'}).then(() => {
if (storage) {
storage.removeItem('token');
}
// Reset the websocket.
client.resetWebsocket();
dispatch({type: actions.LOGOUT});
});
};
//==============================================================================
// AUTH TOKEN
//==============================================================================
export const handleAuthToken = (token) => (dispatch, _, {storage}) => {
if (storage) {
storage.setItem('exp', jwtDecode(token).exp);
storage.setItem('token', token);
}
dispatch({type: 'HANDLE_AUTH_TOKEN'});
};
+6 -7
View File
@@ -14,13 +14,12 @@ import {
HIDE_REJECT_USERNAME_DIALOG
} from '../constants/community';
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
export const fetchAccounts = (query = {}) => (dispatch) => {
export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => {
dispatch(requestFetchAccounts());
coralApi(`/users?${qs.stringify(query)}`)
rest(`/users?${qs.stringify(query)}`)
.then(({result, page, count, limit, totalPages}) =>{
dispatch({
type: FETCH_COMMENTERS_SUCCESS,
@@ -51,15 +50,15 @@ export const newPage = () => ({
type: COMMENTERS_NEW_PAGE
});
export const setRole = (id, role) => (dispatch) => {
return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}})
export const setRole = (id, role) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/role`, {method: 'POST', body: {role}})
.then(() => {
return dispatch({type: SET_ROLE, id, role});
});
};
export const setCommenterStatus = (id, status) => (dispatch) => {
return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}})
export const setCommenterStatus = (id, status) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/status`, {method: 'POST', body: {status}})
.then(() => {
return dispatch({type: SET_COMMENTER_STATUS, id, status});
});
+4 -5
View File
@@ -1,4 +1,3 @@
import coralApi from 'coral-framework/helpers/request';
import * as actions from '../constants/install';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
@@ -106,10 +105,10 @@ export const submitUser = () => (dispatch, getState) => {
});
};
export const finishInstall = () => (dispatch, getState) => {
export const finishInstall = () => (dispatch, getState, {rest}) => {
const data = getState().install.data;
dispatch(installRequest());
return coralApi('/setup', {method: 'POST', body: data})
return rest('/setup', {method: 'POST', body: data})
.then(() => {
dispatch(installSuccess());
dispatch(nextStep());
@@ -129,9 +128,9 @@ const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST});
const checkInstallSuccess = (installed) => ({type: actions.CHECK_INSTALL_SUCCESS, installed});
const checkInstallFailure = (error) => ({type: actions.CHECK_INSTALL_FAILURE, error});
export const checkInstall = (next) => (dispatch) => {
export const checkInstall = (next) => (dispatch, _, {rest}) => {
dispatch(checkInstallRequest());
coralApi('/setup')
rest('/setup')
.then(({installed}) => {
dispatch(checkInstallSuccess(installed));
if (installed) {
+5 -3
View File
@@ -4,15 +4,17 @@ export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open});
export const singleView = () => ({type: actions.SINGLE_VIEW});
// hide shortcuts note
export const hideShortcutsNote = () => {
export const hideShortcutsNote = () => (dispatch, _, {storage}) => {
try {
window.localStorage.setItem('coral:shortcutsNote', 'hide');
if (storage) {
storage.setItem('coral:shortcutsNote', 'hide');
}
} catch (e) {
// above will fail in Safari private mode
}
return {type: actions.HIDE_SHORTCUTS_NOTE};
dispatch({type: actions.HIDE_SHORTCUTS_NOTE});
};
export const setSortOrder = (order) => ({
+4 -5
View File
@@ -1,4 +1,3 @@
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
export const SETTINGS_LOADING = 'SETTINGS_LOADING';
@@ -14,9 +13,9 @@ export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED';
export const fetchSettings = () => (dispatch) => {
export const fetchSettings = () => (dispatch, _, {rest}) => {
dispatch({type: SETTINGS_LOADING});
coralApi('/settings')
rest('/settings')
.then((settings) => {
dispatch({type: SETTINGS_RECEIVED, settings});
})
@@ -41,13 +40,13 @@ export const updateDomainlist = (listName, list) => {
return {type: DOMAINLIST_UPDATED, listName, list};
};
export const saveSettingsToServer = () => (dispatch, getState) => {
export const saveSettingsToServer = () => (dispatch, getState, {rest}) => {
let settings = getState().settings;
if (settings.charCount) {
settings.charCount = parseInt(settings.charCount);
}
dispatch({type: SAVE_SETTINGS_LOADING});
coralApi('/settings', {method: 'PUT', body: settings})
rest('/settings', {method: 'PUT', body: settings})
.then(() => {
dispatch({type: SAVE_SETTINGS_SUCCESS, settings});
})
+6 -7
View File
@@ -1,4 +1,3 @@
import coralApi from '../../../coral-framework/helpers/request';
import * as userTypes from '../constants/users';
import t from 'coral-framework/services/i18n';
@@ -7,9 +6,9 @@ import t from 'coral-framework/services/i18n';
*/
// change status of a user
export const userStatusUpdate = (status, userId, commentId) => {
return (dispatch) => {
return (dispatch, _, {rest}) => {
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
return rest(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
.catch((error) => {
console.error(error);
@@ -21,8 +20,8 @@ export const userStatusUpdate = (status, userId, commentId) => {
// change status of a user
export const sendNotificationEmail = (userId, subject, body) => {
return (dispatch) => {
return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
return (dispatch, _, {rest}) => {
return rest(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
@@ -33,8 +32,8 @@ export const sendNotificationEmail = (userId, subject, body) => {
// let a user edit their username
export const enableUsernameEdit = (userId) => {
return (dispatch) => {
return coralApi(`/users/${userId}/username-enable`, {method: 'POST'})
return (dispatch, _, {rest}) => {
return rest(`/users/${userId}/username-enable`, {method: 'POST'})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
+1 -2
View File
@@ -3,12 +3,11 @@ import {connect} from 'react-redux';
import Layout from '../components/ui/Layout';
import {fetchConfig} from '../actions/config';
import AdminLogin from '../components/AdminLogin';
import {logout} from 'coral-framework/actions/auth';
import {FullLoading} from '../components/FullLoading';
import BanUserDialog from './BanUserDialog';
import SuspendUserDialog from './SuspendUserDialog';
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
import {checkLogin, handleLogin, requestPasswordReset, logout} from '../actions/auth';
import {can} from 'coral-framework/services/perms';
import UserDetail from 'coral-admin/src/containers/UserDetail';
@@ -5,22 +5,24 @@ import SuspendUserDialog from '../components/SuspendUserDialog';
import {hideSuspendUserDialog} from '../actions/suspendUserDialog';
import {withSetCommentStatus, withSuspendUser} from 'coral-framework/graphql/mutations';
import {compose, gql} from 'react-apollo';
import * as notification from 'coral-admin/src/services/notification';
import t, {timeago} from 'coral-framework/services/i18n';
import withQuery from 'coral-framework/hocs/withQuery';
import {getErrorMessages} from 'coral-framework/utils';
import get from 'lodash/get';
import {notify} from 'coral-framework/actions/notification';
class SuspendUserDialogContainer extends Component {
suspendUser = async ({message, until}) => {
const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser} = this.props;
const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser, notify} = this.props;
hideSuspendUserDialog();
try {
const result = await suspendUser({id: userId, message, until});
if (result.data.suspendUser.errors) {
throw result.data.suspendUser.errors;
}
notification.success(
notify(
'success',
t('suspenduser.notify_suspend_until', username, timeago(until)),
);
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
@@ -33,7 +35,7 @@ class SuspendUserDialogContainer extends Component {
}
}
catch(err) {
notification.showMutationErrors(err);
notify('error', getErrorMessages(err));
}
};
@@ -69,6 +71,7 @@ const mapStateToProps = ({suspendUserDialog: {open, userId, username, commentId,
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
hideSuspendUserDialog,
notify,
}, dispatch),
});
+1 -4
View File
@@ -1,6 +1,4 @@
import {add} from 'coral-framework/services/graphqlRegistry';
const extension = {
export default {
mutations: {
SetUserStatus: () => ({
refetchQueries: ['CoralAdmin_Community'],
@@ -11,4 +9,3 @@ const extension = {
},
};
add(extension);
+22 -20
View File
@@ -1,32 +1,34 @@
import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import smoothscroll from 'smoothscroll-polyfill';
import EventEmitter from 'eventemitter2';
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
import {getClient} from 'coral-framework/services/client';
import store from './services/store';
import TalkProvider from 'coral-framework/components/TalkProvider';
import {createContext} from 'coral-framework/services/bootstrap';
import reducers from './reducers';
import App from './components/App';
import 'react-mdl/extra/material.js';
import './graphql';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import graphqlExtension from './graphql';
import pluginsConfig from 'pluginsConfig';
import {toast} from 'react-toastify';
import {createNotificationService} from './services/notification';
import {hideShortcutsNote} from './actions/moderation';
const eventEmitter = new EventEmitter();
function hidrateStore({store, storage}) {
if (storage && storage.getItem('coral:shortcutsNote') === 'hide') {
store.dispatch(hideShortcutsNote());
}
}
// TODO: pass redux actions through the emitter.
loadPluginsTranslations();
injectPluginsReducers();
smoothscroll.polyfill();
const notification = createNotificationService(toast);
const context = createContext({reducers, graphqlExtension, pluginsConfig, notification});
// hidrate Store with external data.
hidrateStore(context);
render(
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
</EventEmitterProvider>
<TalkProvider {...context}>
<App />
</TalkProvider>
, document.querySelector('#root')
);
@@ -5,14 +5,17 @@ const initialState = {
modalOpen: false,
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
shortcutsNoteVisible: 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
};
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
return initialState;
return {
...initialState,
shortcutsNoteVisible: state.shortcutsNoteVisible,
};
case actions.TOGGLE_MODAL:
return {
...state,
@@ -5,17 +5,24 @@ import {Icon} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const refreshIntervalSeconds = 60 * 5;
// TODO: refactor out storage code into redux.
class CountdownTimer extends React.Component {
static contextTypes = {
storage: PropTypes.object,
};
static propTypes = {
handleTimeout: PropTypes.func.isRequired
}
constructor (props) {
super(props);
constructor (props, context) {
super(props, context);
const {storage} = context;
try {
if (window.localStorage.getItem('coral:dashboardNote') === null) {
window.localStorage.setItem('coral:dashboardNote', 'show');
if (storage && storage.getItem('coral:dashboardNote') === null) {
storage.setItem('coral:dashboardNote', 'show');
}
} catch (e) {
@@ -24,7 +31,7 @@ class CountdownTimer extends React.Component {
this.state = {
secondsUntilRefresh: refreshIntervalSeconds,
dashboardNote: window.localStorage.getItem('coral:dashboardNote') || 'show'
dashboardNote: (storage && storage.getItem('coral:dashboardNote')) || 'show'
};
}
@@ -54,8 +61,11 @@ class CountdownTimer extends React.Component {
}
dismissNote = () => {
const {storage} = this.context;
try {
window.localStorage.setItem('coral:dashboardNote', 'hide');
if (storage) {
storage.setItem('coral:dashboardNote', 'hide');
}
} catch (e) {
// when setItem fails in Safari Private mode
@@ -64,7 +74,8 @@ class CountdownTimer extends React.Component {
}
render () {
const hideReloadNote = window.localStorage.getItem('coral:dashboardNote') === 'hide' ||
const {storage} = this.context;
const hideReloadNote = (storage && storage.getItem('coral:dashboardNote') === 'hide') ||
this.state.dashboardNote === 'hide'; // for Safari Incognito
return (
<p
@@ -26,25 +26,27 @@ import {
storySearchChange,
clearState
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {notify} from 'coral-framework/actions/notification';
import {Spinner} from 'coral-ui';
import Moderation from '../components/Moderation';
import Comment from './Comment';
import queueConfig from '../queueConfig';
import baseQueueConfig from '../queueConfig';
function prepareNotificationText(text) {
return truncate(text, {length: 50}).replace('\n', ' ');
}
function getAssetId(props) {
if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) {
if (props.params.tabOrId && !(props.params.tabOrId in props.queueConfig)) {
return props.params.tabOrId;
}
return props.params.id || null;
}
function getTab(props) {
if (props.params.tabOrId && props.params.tabOrId in queueConfig) {
if (props.params.tabOrId && props.params.tabOrId in props.queueConfig) {
return props.params.tabOrId;
}
return props.params.tab || null;
@@ -53,8 +55,15 @@ function getTab(props) {
class ModerationContainer extends Component {
subscriptions = [];
handleCommentChange = (root, comment, notify) => {
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab);
handleCommentChange = (root, comment, notifyText) => {
return handleCommentChange(
root,
comment,
this.props.data.variables.sort,
() => notifyText && this.props.notify('info', notifyText),
this.props.queueConfig,
this.activeTab
);
};
get activeTab() {
@@ -78,10 +87,10 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notify = this.props.auth.user.id === user.id
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -90,10 +99,10 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notify = this.props.auth.user.id === user.id
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -101,8 +110,8 @@ class ModerationContainer extends Component {
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
const notify = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
const notifyText = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -111,8 +120,8 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
const user = comment.actions[comment.actions.length - 1].user;
const notify = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -161,8 +170,8 @@ class ModerationContainer extends Component {
cursor: this.props.root[tab].endCursor,
sort: this.props.data.variables.sort,
asset_id: this.props.data.variables.asset_id,
statuses: queueConfig[tab].statuses,
action_type: queueConfig[tab].action_type,
statuses: this.props.queueConfig[tab].statuses,
action_type: this.props.queueConfig[tab].action_type,
};
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -206,7 +215,7 @@ class ModerationContainer extends Component {
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, queueConfig);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
if (premodEnabled) {
delete currentQueueConfig.new;
} else {
@@ -304,7 +313,7 @@ const commentConnectionFragment = gql`
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(gql`
const withModQueueQuery = withQuery(({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
${Object.keys(queueConfig).map((queue) => `
${queue}: comments(query: {
@@ -352,7 +361,7 @@ const withModQueueQuery = withQuery(gql`
},
});
const withQueueCountPolling = withQuery(gql`
const withQueueCountPolling = withQuery(({queueConfig}) => gql`
query CoralAdmin_ModerationCountPoll($asset_id: ID) {
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
@@ -393,11 +402,13 @@ const mapDispatchToProps = (dispatch) => ({
viewUserDetail,
setSortOrder,
storySearchChange,
clearState
clearState,
notify,
}, dispatch),
});
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withQueueCountPolling,
@@ -1,5 +1,4 @@
import update from 'immutability-helper';
import * as notification from 'coral-admin/src/services/notification';
const limit = 10;
@@ -92,10 +91,7 @@ function getCommentQueues(comment, queueConfig) {
* @param {Object} root current state of the store
* @param {Object} comment comment that was changed
* @param {string} sort current sort order of the queues
* @param {Object} [notify] show know notifications if set
* @param {string} notify.activeQueue current active queue
* @param {string} notify.text notification text to show
* @param {bool} notify.anyQueue if true show the notification when the comment is shown
* @param {string} notify callback to show notification
* in the current active queue besides the 'all' queue.
* @param {Object} queueConfig queue configuration
* @return {Object} next state of the store
@@ -110,7 +106,7 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac
if (notificationShown) {
return;
}
notification.info(notify);
notify();
notificationShown = true;
};
@@ -119,13 +115,13 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
showNotificationOnce(comment);
showNotificationOnce();
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && activeQueue === queue) {
showNotificationOnce(comment);
showNotificationOnce();
}
}
@@ -134,7 +130,7 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac
&& queueHasComment(next, queue, comment.id)
&& activeQueue === queue
) {
showNotificationOnce(comment);
showNotificationOnce();
}
});
return next;
@@ -0,0 +1,26 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
/**
* WithQueueConfig takes a `queueConfig` parameter that is
* passed down as a prop enriched with queue config data from plugins.
*/
export default (queueConfig) => hoistStatics((WrappedComponent) => {
class WithQueueConfig extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
pluginsConfig = this.context.plugins.getModQueueConfigs();
render() {
return <WrappedComponent
{...this.props}
queueConfig={{...queueConfig, ...this.pluginsConfig}}
/>;
}
}
return WithQueueConfig;
});
@@ -1,5 +1,4 @@
import t from 'coral-framework/services/i18n';
import {getModQueueConfigs} from 'coral-framework/helpers/plugins';
export default {
premod: {
@@ -33,5 +32,4 @@ export default {
icon: 'question_answer',
name: t('modqueue.all'),
},
...getModQueueConfigs(),
};
+17 -25
View File
@@ -1,26 +1,18 @@
import t from 'coral-framework/services/i18n';
import {toast} from 'react-toastify';
export function success(msg) {
return toast(msg, {type: 'success'});
}
export function error(msg) {
return toast(msg, {type: 'error'});
}
export function info(msg) {
return toast(msg, {type: 'info'});
}
export function showMutationErrors(error) {
console.error(error);
if (error.errors) {
error.errors.forEach((err) => {
toast(
err.translation_key ? t(`error.${err.translation_key}`) : err,
{type: 'error'}
);
});
}
/**
* createNotificationService returns a notification services based on toast.
* @param {Object} toast
* @return {Object} notification service
*/
export function createNotificationService(toast) {
return {
success(msg) {
toast(msg, {type: 'success'});
},
error(msg) {
toast(msg, {type: 'error'});
},
info(msg) {
toast(msg, {type: 'info'});
},
};
}
-31
View File
@@ -1,31 +0,0 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {getClient} from 'coral-framework/services/client';
const middlewares = [
applyMiddleware(getClient().middleware()),
applyMiddleware(thunk)
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: getClient().reducer()
};
const store = createStore(
combineReducers(coralReducers),
{},
compose(...middlewares)
);
store.coralReducers = coralReducers;
window.coralStore = store;
export default store;
@@ -2,7 +2,7 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {compose} from 'react-apollo';
import {updateOpenStatus, updateConfiguration} from 'coral-framework/actions/asset';
import {updateOpenStatus, updateConfiguration} from 'coral-embed-stream/src/actions/asset';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
+10 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {Router, Route} from 'react-router';
import {history} from 'coral-framework/helpers/router';
import PropTypes from 'prop-types';
import Embed from './containers/Embed';
import {LoginContainer} from 'coral-sign-in/containers/LoginContainer';
@@ -12,6 +12,14 @@ const routes = (
</div>
);
const AppRouter = () => <Router history={history} routes={routes} />;
class AppRouter extends React.Component {
static contextTypes = {
history: PropTypes.object,
};
render() {
return <Router history={this.context.history} routes={routes} />;
}
}
export default AppRouter;
@@ -1,6 +1,5 @@
import * as actions from '../constants/asset';
import coralApi from '../helpers/request';
import {addNotification} from '../actions/notification';
import {notify} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
@@ -12,12 +11,12 @@ const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_R
const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings});
const updateAssetSettingsFailure = (error) => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE, error});
export const updateConfiguration = (newConfig) => (dispatch, getState) => {
export const updateConfiguration = (newConfig) => (dispatch, getState, {rest}) => {
const assetId = getState().asset.id;
dispatch(updateAssetSettingsRequest());
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
rest(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
.then(() => {
dispatch(addNotification('success', t('framework.success_update_settings')));
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(updateAssetSettingsSuccess(newConfig));
})
.catch((error) => {
@@ -26,12 +25,12 @@ export const updateConfiguration = (newConfig) => (dispatch, getState) => {
});
};
export const updateOpenStream = (closedBody) => (dispatch, getState) => {
export const updateOpenStream = (closedBody) => (dispatch, getState, {rest}) => {
const assetId = getState().asset.id;
dispatch(fetchAssetRequest());
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
rest(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
.then(() => {
dispatch(addNotification('success', t('framework.success_update_settings')));
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(fetchAssetSuccess(closedBody));
})
.catch((error) => {
@@ -1,12 +1,8 @@
import jwtDecode from 'jwt-decode';
import bowser from 'bowser';
import * as actions from '../constants/auth';
import * as Storage from '../helpers/storage';
import coralApi, {base} from '../helpers/request';
import pym from '../services/pym';
import {addNotification} from '../actions/notification';
import {notify} from 'coral-framework/actions/notification';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
export const showSignInDialog = () => ({
@@ -59,9 +55,9 @@ export const updateUsername = ({username}) => ({
username
});
export const createUsername = (userId, formData) => (dispatch) => {
export const createUsername = (userId, formData) => (dispatch, _, {rest}) => {
dispatch(createUsernameRequest());
coralApi('/account/username', {method: 'PUT', body: formData})
rest('/account/username', {method: 'PUT', body: formData})
.then(() => {
dispatch(createUsernameSuccess());
dispatch(hideCreateUsernameDialog());
@@ -111,9 +107,11 @@ const signInFailure = (error) => ({
// AUTH TOKEN
//==============================================================================
export const handleAuthToken = (token) => (dispatch) => {
Storage.setItem('exp', jwtDecode(token).exp);
Storage.setItem('token', token);
export const handleAuthToken = (token) => (dispatch, _, {storage}) => {
if (storage) {
storage.setItem('exp', jwtDecode(token).exp);
storage.setItem('token', token);
}
dispatch({type: 'HANDLE_AUTH_TOKEN'});
};
@@ -123,10 +121,10 @@ export const handleAuthToken = (token) => (dispatch) => {
//==============================================================================
export const fetchSignIn = (formData) => {
return (dispatch) => {
return (dispatch, _, {rest}) => {
dispatch(signInRequest());
return coralApi('/auth/local', {method: 'POST', body: formData})
return rest('/auth/local', {method: 'POST', body: formData})
.then(({token}) => {
if (!bowser.safari && !bowser.ios) {
dispatch(handleAuthToken(token));
@@ -171,10 +169,10 @@ const signInFacebookFailure = (error) => ({
error
});
export const fetchSignInFacebook = () => (dispatch) => {
export const fetchSignInFacebook = () => (dispatch, _, {rest}) => {
dispatch(signInFacebookRequest());
window.open(
`${base}/auth/facebook`,
`${rest.uri}/auth/facebook`,
'Continue with Facebook',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
@@ -188,10 +186,10 @@ const signUpFacebookRequest = () => ({
type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST
});
export const fetchSignUpFacebook = () => (dispatch) => {
export const fetchSignUpFacebook = () => (dispatch, _, {rest}) => {
dispatch(signUpFacebookRequest());
window.open(
`${base}/auth/facebook`,
`${rest.uri}/auth/facebook`,
'Continue with Facebook',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
@@ -220,11 +218,11 @@ const signUpRequest = () => ({type: actions.FETCH_SIGNUP_REQUEST});
const signUpSuccess = (user) => ({type: actions.FETCH_SIGNUP_SUCCESS, user});
const signUpFailure = (error) => ({type: actions.FETCH_SIGNUP_FAILURE, error});
export const fetchSignUp = (formData) => (dispatch, getState) => {
export const fetchSignUp = (formData) => (dispatch, getState, {rest}) => {
const redirectUri = getState().auth.redirectUri;
dispatch(signUpRequest());
coralApi('/users', {
rest('/users', {
method: 'POST',
body: formData,
headers: {'X-Pym-Url': redirectUri}
@@ -256,10 +254,10 @@ const forgotPasswordFailure = (error) => ({
error,
});
export const fetchForgotPassword = (email) => (dispatch, getState) => {
export const fetchForgotPassword = (email) => (dispatch, getState, {rest}) => {
dispatch(forgotPasswordRequest(email));
const redirectUri = getState().auth.redirectUri;
coralApi('/account/password/reset', {
rest('/account/password/reset', {
method: 'POST',
body: {email, loc: redirectUri}
})
@@ -275,16 +273,18 @@ export const fetchForgotPassword = (email) => (dispatch, getState) => {
// LOGOUT
//==============================================================================
export const logout = () => (dispatch) => {
return coralApi('/auth', {method: 'DELETE'}).then(() => {
Storage.removeItem('token');
export const logout = () => async (dispatch, _, {rest, client, pym, storage}) => {
await rest('/auth', {method: 'DELETE'});
// Reset the websocket.
resetWebsocket();
if (storage) {
storage.removeItem('token');
}
dispatch({type: actions.LOGOUT});
pym.sendMessage('coral-auth-changed');
});
// Reset the websocket.
client.resetWebsocket();
dispatch({type: actions.LOGOUT});
pym.sendMessage('coral-auth-changed');
};
//==============================================================================
@@ -300,17 +300,19 @@ const checkLoginSuccess = (user, isAdmin) => ({
isAdmin
});
export const checkLogin = () => (dispatch) => {
export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
dispatch(checkLoginRequest());
coralApi('/auth')
rest('/auth')
.then((result) => {
if (!result.user) {
Storage.removeItem('token');
if (storage) {
storage.removeItem('token');
}
throw new Error('Not logged in');
}
// Reset the websocket.
resetWebsocket();
client.resetWebsocket();
dispatch(checkLoginSuccess(result.user));
pym.sendMessage('coral-auth-changed', JSON.stringify(result.user));
@@ -322,10 +324,10 @@ export const checkLogin = () => (dispatch) => {
})
.catch((error) => {
console.error(error);
if (error.status && error.status === 401) {
if (error.status && error.status === 401 && storage) {
// Unauthorized.
Storage.removeItem('token');
storage.removeItem('token');
}
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(checkLoginFailure(errorMessage));
@@ -351,10 +353,10 @@ const verifyEmailFailure = () => ({
type: actions.VERIFY_EMAIL_FAILURE
});
export const requestConfirmEmail = (email) => (dispatch, getState) => {
export const requestConfirmEmail = (email) => (dispatch, getState, {rest}) => {
const redirectUri = getState().auth.redirectUri;
dispatch(verifyEmailRequest());
return coralApi('/users/resend-verify', {
return rest('/users/resend-verify', {
method: 'POST',
body: {email},
headers: {'X-Pym-Url': redirectUri}
@@ -387,11 +389,11 @@ export const setRedirectUri = (uri) => ({
const editUsernameFailure = (error) => ({type: actions.EDIT_USERNAME_FAILURE, error});
const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS});
export const editName = (username) => (dispatch) => {
return coralApi('/account/username', {method: 'PUT', body: {username}})
export const editName = (username) => (dispatch, _, {rest}) => {
return rest('/account/username', {method: 'PUT', body: {username}})
.then(() => {
dispatch(editUsernameSuccess());
dispatch(addNotification('success', t('framework.success_name_update')));
dispatch(notify('success', t('framework.success_name_update')));
})
.catch((error) => {
console.error(error);
@@ -1,11 +1,10 @@
import pym from 'coral-framework/services/pym';
import * as actions from '../constants/stream';
import {buildUrl} from 'coral-framework/utils/url';
import queryString from 'query-string';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
export const viewAllComments = () => {
export const viewAllComments = () => (dispatch, _, {pym}) => {
const search = queryString.stringify({
...queryString.parse(location.search),
@@ -24,10 +23,10 @@ export const viewAllComments = () => {
pym.sendMessage('coral-view-all-comments');
} catch (e) { /* not sure if we're worried about old browsers */ }
return {type: actions.VIEW_ALL_COMMENTS};
dispatch({type: actions.VIEW_ALL_COMMENTS});
};
export const viewComment = (id) => {
export const viewComment = (id) => (dispatch, _, {pym}) => {
const search = queryString.stringify({
...queryString.parse(location.search),
@@ -46,7 +45,7 @@ export const viewComment = (id) => {
pym.sendMessage('coral-view-comment', id);
} catch (e) { /* not sure if we're worried about old browsers */ }
return {type: actions.VIEW_COMMENT, id};
dispatch({type: actions.VIEW_COMMENT, id});
};
export const addCommentClassName = (className) => ({
@@ -87,7 +87,7 @@ class AllCommentsPane extends React.Component {
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
forEachError(error, ({msg}) => {this.props.notify('error', msg);});
});
}
@@ -129,7 +129,7 @@ class AllCommentsPane extends React.Component {
ignoreUser,
setActiveReplyBox,
activeReplyBox,
addNotification,
notify,
disableReply,
postComment,
asset,
@@ -166,7 +166,7 @@ class AllCommentsPane extends React.Component {
disableReply={disableReply}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
notify={notify}
depth={0}
postComment={postComment}
asset={asset}
@@ -152,7 +152,7 @@ export default class Comment extends React.Component {
deleteAction: PropTypes.func.isRequired,
parentId: PropTypes.string,
highlighted: PropTypes.string,
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
liveUpdates: PropTypes.bool,
@@ -205,7 +205,7 @@ export default class Comment extends React.Component {
onClickEdit (e) {
e.preventDefault();
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({isEditing: true});
@@ -235,7 +235,7 @@ export default class Comment extends React.Component {
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
forEachError(error, ({msg}) => {this.props.notify('error', msg);});
});
emit('ui.Comment.showMoreReplies', {id});
return;
@@ -252,7 +252,7 @@ export default class Comment extends React.Component {
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.setActiveReplyBox(this.props.comment.id);
} else {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
this.props.notify('error', t('error.NOT_AUTHORIZED'));
}
return;
}
@@ -331,7 +331,7 @@ export default class Comment extends React.Component {
deleteAction,
disableReply,
maxCharCount,
addNotification,
notify,
charCountEnable,
showSignInDialog,
liveUpdates,
@@ -482,7 +482,7 @@ export default class Comment extends React.Component {
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
notify={notify} />
</span>
}
{ !isActive &&
@@ -494,7 +494,7 @@ export default class Comment extends React.Component {
this.state.isEditing
? <EditableCommentContent
editComment={this.editComment}
addNotification={addNotification}
notify={notify}
comment={comment}
currentUser={currentUser}
charCountEnable={charCountEnable}
@@ -546,7 +546,7 @@ export default class Comment extends React.Component {
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
addNotification={addNotification}
notify={notify}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
@@ -567,7 +567,7 @@ export default class Comment extends React.Component {
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
addNotification={addNotification}
notify={notify}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
@@ -584,7 +584,7 @@ export default class Comment extends React.Component {
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
notify={notify}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
@@ -17,7 +17,7 @@ export class EditableCommentContent extends React.Component {
static propTypes = {
// show notification to the user (e.g. for errors)
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
// comment that is being edited
comment: PropTypes.shape({
@@ -74,26 +74,26 @@ export class EditableCommentContent extends React.Component {
handleSubmit = async () => {
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({loadingState: 'loading'});
const {editComment, addNotification, stopEditing} = this.props;
const {editComment, notify, stopEditing} = this.props;
if (typeof editComment !== 'function') {return;}
let response;
try {
response = await editComment({body: this.state.body});
this.setState({loadingState: 'success'});
const status = response.data.editComment.comment.status;
notifyForNewCommentStatus(this.props.addNotification, status);
notifyForNewCommentStatus(this.props.notify, status);
if (typeof stopEditing === 'function') {
stopEditing();
}
} catch (error) {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => addNotification('error', msg));
forEachError(error, ({msg}) => notify('error', msg));
}
}
@@ -59,7 +59,7 @@ class Stream extends React.Component {
commentClassNames,
root: {asset, asset: {comment, comments, totalCommentCount}},
postComment,
addNotification,
notify,
editComment,
postFlag,
postDontAgree,
@@ -147,7 +147,7 @@ class Stream extends React.Component {
/>}
{showCommentBox &&
<CommentBox
addNotification={addNotification}
notify={notify}
postComment={postComment}
appendItemArray={appendItemArray}
updateItem={updateItem}
@@ -185,7 +185,7 @@ class Stream extends React.Component {
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
notify={notify}
depth={0}
disableReply={!open}
postComment={postComment}
@@ -241,7 +241,7 @@ class Stream extends React.Component {
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
notify={notify}
disableReply={!open}
postComment={postComment}
asset={asset}
@@ -270,7 +270,7 @@ class Stream extends React.Component {
}
Stream.propTypes = {
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
// dispatch action to ignore another user
@@ -18,7 +18,7 @@ export class TopRightMenu extends React.Component {
ignoreUser: PropTypes.func,
// show notification to the user (e.g. for errors)
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
@@ -27,7 +27,7 @@ export class TopRightMenu extends React.Component {
};
}
render() {
const {comment, ignoreUser, addNotification} = this.props;
const {comment, ignoreUser, notify} = this.props;
// timesReset is used as Toggleable key so it re-renders on reset (closing the toggleable)
const reset = () => this.setState({timesReset: this.state.timesReset + 1});
@@ -40,7 +40,7 @@ export class TopRightMenu extends React.Component {
try {
await ignoreUser({id});
} catch (error) {
addNotification('error', 'Failed to ignore user');
notify('error', 'Failed to ignore user');
throw error;
}
};
@@ -8,22 +8,25 @@ import branch from 'recompose/branch';
import renderComponent from 'recompose/renderComponent';
import {Spinner} from 'coral-ui';
import * as authActions from 'coral-framework/actions/auth';
import * as assetActions from 'coral-framework/actions/asset';
import pym from 'coral-framework/services/pym';
import * as authActions from '../actions/auth';
import * as assetActions from '../actions/asset';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from './Stream';
import {addNotification} from 'coral-framework/actions/notification';
import {notify} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
import {setActiveTab} from '../actions/embed';
const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions;
const {fetchAssetSuccess} = assetActions;
class EmbedContainer extends React.Component {
static contextTypes = {
pym: PropTypes.object,
};
subscriptions = [];
subscribeToUpdates(props = this.props) {
@@ -31,19 +34,19 @@ class EmbedContainer extends React.Component {
const newSubscriptions = [{
document: USER_BANNED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_account_has_been_banned'));
notify('info', t('your_account_has_been_banned'));
},
},
{
document: USER_SUSPENDED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_account_has_been_suspended'));
notify('info', t('your_account_has_been_suspended'));
},
},
{
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_username_has_been_rejected'));
notify('info', t('your_username_has_been_rejected'));
},
}];
@@ -95,7 +98,7 @@ class EmbedContainer extends React.Component {
if (!get(prevProps, 'root.asset.comment') && get(this.props, 'root.asset.comment')) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0);
setTimeout(() => this.context.pym.scrollParentToChildEl('talk-embed-stream-container'), 0);
}
}
@@ -190,7 +193,7 @@ const mapDispatchToProps = (dispatch) =>
checkLogin,
setActiveTab,
fetchAssetSuccess,
addNotification,
notify,
focusSignInDialog,
blurSignInDialog,
hideSignInDialog,
@@ -8,7 +8,7 @@ import {
withDeleteAction, withIgnoreUser, withEditComment
} from 'coral-framework/graphql/mutations';
import * as authActions from 'coral-framework/actions/auth';
import * as authActions from 'coral-embed-stream/src/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
import Stream from '../components/Stream';
@@ -26,7 +26,7 @@ import {
} from '../graphql/utils';
const {showSignInDialog, editName} = authActions;
const {addNotification} = notificationActions;
const {notify} = notificationActions;
class StreamContainer extends React.Component {
subscriptions = [];
@@ -303,7 +303,7 @@ const mapStateToProps = (state) => ({
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showSignInDialog,
addNotification,
notify,
setActiveReplyBox,
editName,
viewAllComments,
@@ -2,13 +2,15 @@ import React from 'react';
import StreamTabPanel from '../components/StreamTabPanel';
import {connect} from 'react-redux';
import omit from 'lodash/omit';
import {getSlotComponents, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import {Tab, TabPane} from 'coral-ui';
import {getShallowChanges} from 'coral-framework/utils';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
class StreamTabPanelContainer extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
componentDidMount() {
this.fallbackAllTab();
@@ -43,28 +45,37 @@ class StreamTabPanelContainer extends React.Component {
}
getSlotComponents(slot, props = this.props) {
return getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData);
const {plugins} = this.context;
return plugins.getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData);
}
getPluginTabElements(props = this.props) {
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
active={this.props.activeTab === PluginComponent.talkPluginName}
/>
</Tab>
));
const {plugins} = this.context;
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => {
const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData);
return (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...pluginProps}
active={this.props.activeTab === PluginComponent.talkPluginName}
/>
</Tab>
);
});
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
/>
</TabPane>
));
const {plugins} = this.context;
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => {
const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData);
return (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...pluginProps}
/>
</TabPane>
);
});
}
render() {
@@ -1,10 +1,9 @@
import {gql} from 'react-apollo';
import {add} from 'coral-framework/services/graphqlRegistry';
import update from 'immutability-helper';
import uuid from 'uuid/v4';
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
const extension = {
export default {
fragments: {
EditCommentResponse: gql`
fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse {
@@ -223,4 +222,3 @@ const extension = {
},
};
add(extension);
+11 -29
View File
@@ -1,27 +1,19 @@
import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {checkLogin, handleAuthToken, logout} from 'coral-framework/actions/auth';
import './graphql';
import {checkLogin, handleAuthToken, logout} from 'coral-embed-stream/src/actions/auth';
import graphqlExtension from './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import {getStore, injectReducers, addListener} from 'coral-framework/services/store';
import {getClient} from 'coral-framework/services/client';
import {createReduxEmitter} from 'coral-framework/services/events';
import pym from 'coral-framework/services/pym';
import {createContext} from 'coral-framework/services/bootstrap';
import AppRouter from './AppRouter';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
import EventEmitter from 'eventemitter2';
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
import TalkProvider from 'coral-framework/components/TalkProvider';
import pluginsConfig from 'pluginsConfig';
const store = getStore();
const client = getClient();
const eventEmitter = new EventEmitter({wildcard: true});
const context = createContext({reducers, graphqlExtension, pluginsConfig});
loadPluginsTranslations();
injectPluginsReducers();
injectReducers(reducers);
// TODO: move init code into `bootstrap` service after auth has been refactored.
const {store, pym} = context;
function inIframe() {
try {
@@ -57,21 +49,11 @@ if (!window.opener) {
} else {
init();
}
// Pass any events through our parent.
eventEmitter.onAny((eventName, value) => {
pym.sendMessage('event', JSON.stringify({eventName, value}));
});
// Add a redux listener to pass through all actions to our event emitter.
addListener(createReduxEmitter(eventEmitter));
}
render(
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={client} store={store}>
<AppRouter />
</ApolloProvider>
</EventEmitterProvider>
<TalkProvider {...context}>
<AppRouter />
</TalkProvider>
, document.querySelector('#talk-embed-stream-container')
);
@@ -1,8 +1,14 @@
import auth from './auth';
import asset from './asset';
import embed from './embed';
import config from './config';
import stream from './stream';
import {reducer as commentBox} from '../../../talk-plugin-commentbox';
export default {
auth,
asset,
commentBox,
embed,
config,
stream,
@@ -1,5 +1,5 @@
import * as actions from '../constants/stream';
import * as authActions from 'coral-framework/constants/auth';
import * as authActions from '../constants/auth';
function getQueryVariable(variable) {
let query = window.location.search.substring(1);
+18 -8
View File
@@ -1,12 +1,22 @@
import pym from '../services/pym';
import * as actions from '../constants/notification';
export const addNotification = (notifType, text) => {
pym.sendMessage('coral-alert', `${notifType}|${text}`);
return {type: actions.ADD_NOTIFICATION, notifType, text};
};
export const notify = (kind, msg) => (dispatch, _, {notification}) => {
const messages = Array.isArray(msg) ? msg : [msg];
export const clearNotification = () => {
pym.sendMessage('coral-clear-notification');
return {type: actions.CLEAR_NOTIFICATION};
messages.forEach((message) => {
switch (kind) {
case 'error':
notification.error(message);
break;
case 'info':
notification.info(message);
break;
case 'success':
notification.success(message);
break;
default:
throw new Error(`Unknown notification kind ${kind}`);
}
dispatch({type: actions.NOTIFY, kind, message});
});
};
@@ -1,13 +1,16 @@
import {Component, cloneElement, Children} from 'react';
import PropTypes from 'prop-types';
import {findDOMNode} from 'react-dom';
import pym from 'coral-framework/services/pym';
export default class ClickOutside extends Component {
static propTypes = {
onClickOutside: PropTypes.func.isRequired
};
static contextTypes = {
pym: PropTypes.object,
};
domNode = null;
handleClick = (e) => {
@@ -18,14 +21,20 @@ export default class ClickOutside extends Component {
};
componentDidMount() {
const {pym} = this.context;
this.domNode = findDOMNode(this);
document.addEventListener('click', this.handleClick, true);
pym.onMessage('click', this.handleClick);
if (pym) {
pym.onMessage('click', this.handleClick);
}
}
componentWillUnmount() {
const {pym} = this.context;
document.removeEventListener('click', this.handleClick, true);
pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick);
if (pym) {
pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick);
}
}
render() {
@@ -1,18 +0,0 @@
import React from 'react';
const PropTypes = require('prop-types');
class EventEmitterProvider extends React.Component {
getChildContext() {
return {eventEmitter: this.props.eventEmitter};
}
render() {
return this.props.children;
}
}
EventEmitterProvider.childContextTypes = {
eventEmitter: PropTypes.object,
};
export default EventEmitterProvider;
@@ -1,11 +1,13 @@
import React from 'react';
import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
class IfSlotIsEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
@@ -22,7 +24,7 @@ class IfSlotIsEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
return this.context.plugins.isSlotEmpty(slot, reduxState, rest);
}
render() {
@@ -1,11 +1,13 @@
import React from 'react';
import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
class IfSlotIsNotEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
@@ -22,7 +24,7 @@ class IfSlotIsNotEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
return this.context.plugins.isSlotEmpty(slot, reduxState, rest);
}
render() {
+10 -3
View File
@@ -2,14 +2,18 @@ import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import omit from 'lodash/omit';
import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual';
import {getShallowChanges} from 'coral-framework/utils';
const emptyConfig = {};
class Slot extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
@@ -30,15 +34,18 @@ class Slot extends React.Component {
}
getChildren(props = this.props) {
return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
const {plugins} = this.context;
return plugins.getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
}
render() {
const {plugins} = this.context;
const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props;
let children = this.getChildren();
const pluginConfig = reduxState.config.pluginConfig || emptyConfig;
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData)} />;
const props = plugins.getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData);
children = <DefaultComponent {...props} />;
}
return (
@@ -0,0 +1,40 @@
import React from 'react';
const PropTypes = require('prop-types');
import {ApolloProvider} from 'react-apollo';
class TalkProvider extends React.Component {
getChildContext() {
return {
eventEmitter: this.props.eventEmitter,
pym: this.props.pym,
plugins: this.props.plugins,
rest: this.props.rest,
graphqlRegistry: this.props.graphqlRegistry,
notification: this.props.notification,
storage: this.props.storage,
history: this.props.history,
};
}
render() {
const {children, client, store} = this.props;
return (
<ApolloProvider client={client} store={store}>
{children}
</ApolloProvider>
);
}
}
TalkProvider.childContextTypes = {
pym: PropTypes.object,
eventEmitter: PropTypes.object,
plugins: PropTypes.object,
rest: PropTypes.func,
graphqlRegistry: PropTypes.object,
notification: PropTypes.object,
storage: PropTypes.object,
history: PropTypes.object,
};
export default TalkProvider;
@@ -1,2 +1 @@
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION';
export const NOTIFY = 'NOTIFY';
-173
View File
@@ -1,173 +0,0 @@
import React from 'react';
import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
import merge from 'lodash/merge';
import flattenDeep from 'lodash/flattenDeep';
import isEmpty from 'lodash/isEmpty';
import flatten from 'lodash/flatten';
import mapValues from 'lodash/mapValues';
import {loadTranslations} from 'coral-framework/services/i18n';
import {injectReducers} from 'coral-framework/services/store';
import {getDisplayName} from 'coral-framework/helpers/hoc';
import camelize from './camelize';
import plugins from 'pluginsConfig';
import uuid from 'uuid/v4';
// This is returned for pluginConfig when it is empty.
const emptyConfig = {};
export function getSlotComponents(slot, reduxState, props = {}, queryData = {}) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return flatten(plugins
// Filter out components that have slots and have been disabled in `plugin_config`
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components))
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot])
)
.filter((component) => {
if(!component.isExcluded) {
return true;
}
let resolvedProps = getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
return !component.isExcluded(resolvedProps);
});
}
export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData).length === 0;
}
// Memoize the warnings so we only show them once.
const memoizedWarnings = [];
// withWarnings decorates the props of queryData with a proxy that
// prints a warning when accessing deeper props.
function withWarnings(component, queryData) {
if (process.env.NODE_ENV !== 'production' && window.Proxy) {
// Show warnings when accessing queryData only when not in production.
return mapValues(queryData, (value, key) => {
// Keep null values..
if (!queryData[key]) {
return queryData[key];
}
return new Proxy(queryData[key], {
get(target, name) {
// Only care about the components defined in the plugins.
if (component.talkPluginName) {
const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`;
if (memoizedWarnings.indexOf(warning) === -1) {
console.warn(warning);
memoizedWarnings.push(warning);
}
}
return queryData[key][name];
}
});
});
}
return queryData;
}
/**
* getSlotComponentProps calculate the props we would pass to the slot component.
* query datas are only passed to the component if it is defined in `component.fragments`.
*/
export function getSlotComponentProps(component, reduxState, props, queryData) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return {
...props,
config: pluginConfig,
...(
component.fragments
? pick(queryData, Object.keys(component.fragments))
: withWarnings(component, queryData)
)
};
}
/**
* Returns React Elements for given slot.
*/
export function getSlotElements(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData)
.map((component, i) => {
return React.createElement(component, {key: i, ...getSlotComponentProps(component, reduxState, props, queryData)});
});
}
export function getSlotFragments(slot, part) {
const components = uniq(flattenDeep(plugins
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
.map((o) => o.module.slots[slot])
));
const documents = components
.map((c) => c.fragments)
.filter((fragments) => fragments && fragments[part])
.reduce((res, fragments) => {
res.push(fragments[part]);
return res;
}, []);
return documents;
}
export function getGraphQLExtensions() {
return plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => !isEmpty(o));
}
export function getModQueueConfigs() {
return merge(...plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
function getTranslations() {
return plugins
.map((o) => o.module.translations)
.filter((o) => o);
}
export function loadPluginsTranslations() {
getTranslations().forEach((t) => loadTranslations(t));
}
export function injectPluginsReducers() {
const reducers = merge(
...plugins
.filter((o) => o.module.reducer)
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
);
injectReducers(reducers);
}
function addMetaDataToSlotComponents() {
// Add talkPluginName to Slot Components.
plugins.forEach((plugin) => {
const slots = plugin.module.slots;
slots && Object.keys(slots).forEach((slot) => {
slots[slot].forEach((component) => {
// Attach plugin name to the component
component.talkPluginName = plugin.name;
// Attach uuid to the component
component.talkUuid = uuid();
});
});
});
}
addMetaDataToSlotComponents();
-84
View File
@@ -1,84 +0,0 @@
import bowser from 'bowser';
import * as Storage from './storage';
import merge from 'lodash/merge';
import {getStore} from 'coral-framework/services/store';
import {BASE_PATH} from 'coral-framework/constants/url';
/**
* getAuthToken returns the active auth token or null
* Note: this method does not have access to the cookie based token used by
* browsers that don't allow us to use cross domain iframe local storage.
* @return {string|null}
*/
export const getAuthToken = () => {
let state = getStore().getState();
if (state.config.auth_token) {
// if an auth_token exists in config, use it.
return state.config.auth_token;
} else if (!bowser.safari && !bowser.ios) {
// Use local storage auth tokens where there's a stable api.
return Storage.getItem('token');
}
return null;
};
const buildOptions = (inputOptions = {}) => {
const defaultOptions = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin'
};
let options = merge({}, defaultOptions, inputOptions);
// Apply authToken header
let authToken = getAuthToken();
if (authToken !== null) {
options.headers.Authorization = `Bearer ${authToken}`;
}
if (options.method.toLowerCase() !== 'get') {
options.body = JSON.stringify(options.body);
}
return options;
};
const handleResp = (res) => {
if (res.status > 399) {
return res.json().then((err) => {
let message = err.message || res.status;
const error = new Error();
if (err.error && err.error.metadata && err.error.metadata.message) {
error.metadata = err.error.metadata.message;
}
if (err.error && err.error.translation_key) {
error.translation_key = err.error.translation_key;
}
error.message = message;
error.status = res.status;
throw error;
});
} else if (res.status === 204) {
return res.text();
} else {
return res.json();
}
};
export const base = `${BASE_PATH}api/v1`;
export default (url, options) => {
return fetch(`${base}${url}`, buildOptions(options)).then(handleResp);
};
-7
View File
@@ -1,7 +0,0 @@
import {useBasename} from 'history';
import {browserHistory} from 'react-router';
import {BASE_PATH} from 'coral-framework/constants/url';
export const history = useBasename(() => browserHistory)({
basename: BASE_PATH
});
-92
View File
@@ -1,92 +0,0 @@
let available, error;
function storageAvailable(type) {
let storage = window[type], x = '__storage_test__';
try {
storage.setItem(x, x);
storage.removeItem(x);
return true;
} catch (e) {
error = e;
return (
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0
);
}
}
function lazyCheckStorage() {
if (typeof available === 'undefined') {
available = storageAvailable('localStorage');
}
}
export function getItem(item = '') {
lazyCheckStorage();
if (available) {
return localStorage.getItem(item);
} else {
console.error(
`Cannot get from localStorage. localStorage is not available. ${error}`
);
}
}
export function setItem(item = '', value) {
lazyCheckStorage();
if (available) {
return localStorage.setItem(item, value);
} else {
console.error(
`Cannot set localStorage. localStorage is not available. ${error}`
);
}
}
export function removeItem(item = '') {
lazyCheckStorage();
if (available) {
return localStorage.removeItem(item);
} else {
console.error(
`Cannot remove item from localStorage. localStorage is not available. ${error}`
);
}
}
export function clear() {
lazyCheckStorage();
if (available) {
return localStorage.clear();
} else {
console.error(
`Cannot clear localStorage. localStorage is not available. ${error}`
);
}
}
// window.addEventListener('storage', function(e) {
// const msg = `${e.key} " was changed in page ${e.url} from ${e.oldValue} to ${e.newValue}`;
// console.log(msg);
// });
+17 -2
View File
@@ -1,9 +1,9 @@
import React from 'react';
import graphql from 'graphql-anywhere';
import {resolveFragments} from 'coral-framework/services/graphqlRegistry';
import mapValues from 'lodash/mapValues';
import hoistStatics from 'recompose/hoistStatics';
import {getShallowChanges} from 'coral-framework/utils';
import PropTypes from 'prop-types';
import union from 'lodash/union';
// TODO: Should not depend on `props.data`
@@ -63,7 +63,22 @@ function hasEqualLeaves(a, b, path = '') {
export default (fragments) => hoistStatics((BaseComponent) => {
class WithFragments extends React.Component {
fragments = mapValues(fragments, (val) => resolveFragments(val));
static contextTypes = {
graphqlRegistry: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
fragments = mapValues(fragments, (val) => this.resolveDocument(val));
fragmentKeys = Object.keys(fragments).sort();
// Cache variables between lifecycles to speed up render.
+14 -3
View File
@@ -4,7 +4,6 @@ import merge from 'lodash/merge';
import uniq from 'lodash/uniq';
import flatten from 'lodash/flatten';
import isEmpty from 'lodash/isEmpty';
import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
import {getDefinitionName, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
@@ -43,8 +42,20 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
static contextTypes = {
eventEmitter: PropTypes.object,
store: PropTypes.object,
graphqlRegistry: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
@@ -56,7 +67,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
propsWrapper = (data) => {
const name = getDefinitionName(document);
const callbacks = getMutationOptions(name);
const callbacks = this.graphqlRegistry.getMutationOptions(name);
const mutate = (base) => {
const variables = base.variables || config.options.variables;
const configs = callbacks.map((cb) => cb({variables, state: this.context.store.getState()}));
@@ -167,7 +178,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
getWrapped = () => {
if (!this.memoized) {
this.memoized = graphql(resolveFragments(document), {...config, props: this.propsWrapper})(WrappedComponent);
this.memoized = graphql(this.resolveDocument(document), {...config, props: this.propsWrapper})(WrappedComponent);
}
return this.memoized;
};
+22 -8
View File
@@ -1,6 +1,5 @@
import * as React from 'react';
import {graphql} from 'react-apollo';
import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import hoistStatics from 'recompose/hoistStatics';
@@ -37,17 +36,28 @@ function networkStatusToString(networkStatus) {
* apply query options registered in the graphRegistry.
*/
export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const name = getDefinitionName(document);
return class WithQuery extends React.Component {
static contextTypes = {
eventEmitter: PropTypes.object,
graphqlRegistry: PropTypes.object,
};
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
lastNetworkStatus = null;
data = null;
name = '';
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
emitWhenNeeded(data) {
const {variables, networkStatus} = data;
@@ -93,11 +103,12 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMore: (stmArgs) => {
const resolvedDocument = this.resolveDocument(stmArgs.document);
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
...stmArgs,
document: resolveFragments(stmArgs.document),
document: resolvedDocument,
onError: (err) => {
if (stmArgs.onErr) {
return stmArgs.onErr(err);
@@ -107,7 +118,8 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
});
},
fetchMore: (lmArgs) => {
const fetchName = getDefinitionName(lmArgs.query);
const resolvedDocument = this.resolveDocument(lmArgs.query);
const fetchName = getDefinitionName(resolvedDocument);
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.begin`,
{variables: lmArgs.variables});
@@ -115,7 +127,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
...lmArgs,
query: resolveFragments(lmArgs.query),
query: resolvedDocument,
})
.then((res) => {
this.context.eventEmitter.emit(
@@ -156,7 +168,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const base = (typeof this.wrappedConfig.options === 'function')
? this.wrappedConfig.options(data)
: this.wrappedConfig.options;
const configs = getQueryOptions(name);
const configs = this.graphqlRegistry.getQueryOptions(name);
const reducerCallbacks =
[base.reducer || ((i) => i)]
.concat(...configs.map((cfg) => cfg.reducer))
@@ -178,8 +190,10 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
getWrapped = () => {
if (!this.memoized) {
const resolvedDocument = this.resolveDocument(document);
this.name = getDefinitionName(resolvedDocument);
this.memoized = graphql(
resolveFragments(document),
resolvedDocument,
{...this.wrappedConfig, options: this.wrappedOptions},
)(WrappedComponent);
}
-9
View File
@@ -1,9 +0,0 @@
import auth from './auth';
import asset from './asset';
import {reducer as commentBox} from '../../talk-plugin-commentbox';
export default {
auth,
asset,
commentBox,
};
+128
View File
@@ -0,0 +1,128 @@
import {createStore} from './store';
import {createClient, apolloErrorReporter} from './client';
import pym from './pym';
import EventEmitter from 'eventemitter2';
import {createReduxEmitter} from './events';
import {createRestClient} from './rest';
import thunk from 'redux-thunk';
import {loadTranslations} from './i18n';
import bowser from 'bowser';
import {BASE_PATH} from 'coral-framework/constants/url';
import {createPluginsService} from './plugins';
import {createNotificationService} from './notification';
import {createGraphQLRegistry} from './graphqlRegistry';
import globalFragments from 'coral-framework/graphql/fragments';
import {createStorage} from 'coral-framework/services/storage';
import {createHistory} from 'coral-framework/services/history';
/**
* getAuthToken returns the active auth token or null
* Note: this method does not have access to the cookie based token used by
* browsers that don't allow us to use cross domain iframe local storage.
* @return {string|null}
*/
const getAuthToken = (store, storage) => {
let state = store.getState();
if (state.config.auth_token) {
// if an auth_token exists in config, use it.
return state.config.auth_token;
} else if (!bowser.safari && !bowser.ios && storage) {
// Use local storage auth tokens where there's a stable api.
return storage.getItem('token');
}
return null;
};
/**
* createContext setups and returns Talk dependencies that should be
* passed to `TalkProvider`.
* @param {Object} [config] configuration
* @param {Object} [config.reducers] extra reducers to add to redux
* @param {Array} [config.pluginsConfig] plugin configuration as returned by importing pluginsConfig
* @param {Object} [config.graphqlExtensions] additional extension to the graphql framework
* @param {Object} [config.notification] replace default notification service
* @return {Object} context
*/
export function createContext({reducers = {}, pluginsConfig = [], graphqlExtension = {}, notification} = {}) {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const eventEmitter = new EventEmitter({wildcard: true});
const storage = createStorage();
const history = createHistory(BASE_PATH);
let store = null;
const token = () => {
// 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
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return getAuthToken(store, storage);
};
const rest = createRestClient({
uri: `${BASE_PATH}api/v1`,
token,
});
const client = createClient({
uri: `${BASE_PATH}api/v1/graph/ql`,
liveUri: `${protocol}://${location.host}${BASE_PATH}api/v1/live`,
token,
});
const plugins = createPluginsService(pluginsConfig);
const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins));
if (!notification) {
// Use default notification service (pym based)
notification = createNotificationService(pym);
}
const context = {
client,
pym,
plugins,
eventEmitter,
rest,
graphqlRegistry,
notification,
storage,
history,
};
// Load framework fragments.
Object.keys(globalFragments).forEach((key) => graphqlRegistry.addFragment(key, globalFragments[key]));
// Register graphql extension
graphqlRegistry.add(graphqlExtension);
// Register plugin graphql extensions.
plugins.getGraphQLExtensions().forEach((ext) => graphqlRegistry.add(ext));
// Load plugin translations.
plugins.getTranslations().forEach((t) => loadTranslations(t));
// Pass any events through our parent.
eventEmitter.onAny((eventName, value) => {
pym.sendMessage('event', JSON.stringify({eventName, value}));
});
const finalReducers = {
...reducers,
...plugins.getReducers(),
apollo: client.reducer(),
};
store = createStore(finalReducers, [
client.middleware(),
thunk.withExtraArgument(context),
apolloErrorReporter,
createReduxEmitter(eventEmitter),
]);
return {
...context,
store,
};
}
+59 -42
View File
@@ -1,64 +1,66 @@
import ApolloClient, {addTypename, IntrospectionFragmentMatcher} from 'apollo-client';
import {networkInterface} from './transport';
import ApolloClient, {addTypename, IntrospectionFragmentMatcher, createNetworkInterface} from 'apollo-client';
import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
import MessageTypes from 'subscriptions-transport-ws/dist/message-types';
import {getAuthToken} from '../helpers/request';
import introspectionQueryResultData from '../graphql/introspection.json';
import {BASE_PATH} from 'coral-framework/constants/url';
let client, wsClient = null, wsClientToken = null;
export function resetWebsocket() {
if (wsClient === null) {
// Nothing to reset!
return;
// Redux middleware to report any errors to the console.
export const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close();
// Reconnect to the server.
wsClient.connect();
// Reregister all subscriptions (uses non public api).
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
Object.keys(wsClient.operations).forEach((id) => {
wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options);
});
function resolveToken(token) {
return typeof token === 'function' ? token() : token;
}
export function getClient(options = {}) {
if (client) {
return client;
}
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
wsClient = new SubscriptionClient(`${protocol}://${location.host}${BASE_PATH}api/v1/live`, {
/**
* createClient setups and returns an Apollo GraphQL Client
* @param {Object} [options] configuration
* @param {string|function} [options.token] auth token
* @param {string} [options.uri] uri of the graphql server
* @param {string} [options.liveUri] uri of the graphql subscription server
* @return {Object} apollo client
*/
export function createClient(options = {}) {
const {token, uri, liveUri} = options;
const wsClient = new SubscriptionClient(liveUri, {
reconnect: true,
lazy: true,
connectionParams: {
get token() {
wsClientToken = getAuthToken();
// 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
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return wsClientToken;
}
get token() { return resolveToken(token); },
}
});
const networkInterface = createNetworkInterface({
uri,
opts: {
credentials: 'same-origin'
}
});
networkInterface.use([{
applyMiddleware(req, next) {
if (!req.options.headers) {
req.options.headers = {}; // Create the header object if needed.
}
let authToken = resolveToken(token);
if (authToken) {
req.options.headers['authorization'] = `Bearer ${authToken}`;
}
next();
}
}]);
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
networkInterface,
wsClient,
);
client = new ApolloClient({
...options,
const client = new ApolloClient({
connectToDevTools: true,
addTypename: true,
fragmentMatcher: new IntrospectionFragmentMatcher({introspectionQueryResultData}),
@@ -72,5 +74,20 @@ export function getClient(options = {}) {
networkInterface: networkInterfaceWithSubscriptions,
});
client.resetWebsocket = () => {
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close();
// Reconnect to the server.
wsClient.connect();
// Reregister all subscriptions (uses non public api).
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
Object.keys(wsClient.operations).forEach((id) => {
wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options);
});
};
return client;
}
+10 -2
View File
@@ -1,5 +1,11 @@
/**
* createReduxEmitter returns a redux middleware proxying redux actions to
* the event emitter
* @param {Object} eventEmitter
* @return {function} redux middleware
*/
export function createReduxEmitter(eventEmitter) {
return (action) => {
return () => (next) => (action) => {
// Handle apollo actions.
if (action.type.startsWith('APOLLO_')) {
@@ -7,8 +13,10 @@ export function createReduxEmitter(eventEmitter) {
const {operationName, variables, result: {data}} = action;
eventEmitter.emit(`subscription.${operationName}.data`, {variables, data});
}
return;
return next(action);
}
eventEmitter.emit(`action.${action.type}`, {action});
return next(action);
};
}
+246 -253
View File
@@ -1,6 +1,4 @@
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
import {getGraphQLExtensions, getSlotFragments} from 'coral-framework/helpers/plugins';
import globalFragments from 'coral-framework/graphql/fragments';
import uniq from 'lodash/uniq';
import {gql} from 'react-apollo';
@@ -14,272 +12,267 @@ import {gql} from 'react-apollo';
*/
gql.disableFragmentWarnings();
const fragments = {};
const mutationOptions = {};
const queryOptions = {};
const getTypeName = (ast) => ast.definitions[0].typeCondition.name.value;
/**
* Add fragment
*
* Example:
* addFragment('MyFragment', gql`
* fragment Plugin_MyFragment on Comment {
* body
* }
* `);
*/
export function addFragment(key, document) {
const type = getTypeName(document);
const name = getDefinitionName(document);
if (!(key in fragments)) {
fragments[key] = {type, names: [name], documents: [document]};
} else {
if (type !== fragments[key].type) {
console.error(`Type mismatch ${type} !== ${fragments[key].type}`);
class GraphQLRegistry {
fragments = {};
mutationOptions = {};
queryOptions = {};
constructor(getSlotFragments) {
this.getSlotFragments = getSlotFragments;
}
/**
* Add fragment
*
* Example:
* addFragment('MyFragment', gql`
* fragment Plugin_MyFragment on Comment {
* body
* }
* `);
*/
addFragment(key, document) {
const type = getTypeName(document);
const name = getDefinitionName(document);
if (!(key in this.fragments)) {
this.fragments[key] = {type, names: [name], documents: [document]};
} else {
if (type !== this.fragments[key].type) {
console.error(`Type mismatch ${type} !== ${this.fragments[key].type}`);
}
this.fragments[key].names.push(name);
this.fragments[key].documents.push(document);
}
fragments[key].names.push(name);
fragments[key].documents.push(document);
}
}
/**
* Add mutation options.
*
* Example:
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* addMutationOptions('PostComment', ({variables, state}) => ({
* optimisticResponse: {
* CreateComment: {
* extra: '',
* },
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
*/
export function addMutationOptions(key, config) {
if (!(key in mutationOptions)) {
mutationOptions[key] = [config];
} else {
mutationOptions[key].push(config);
}
}
/**
* Add query options.
*
* Example:
* addQueryOptions('EmbedQuery', {
* reducer: (previousResult, action, variables) => previousResult,
* });
*/
export function addQueryOptions(key, config) {
if (!(key in queryOptions)) {
queryOptions[key] = [config];
} else {
queryOptions[key].push(config);
}
}
/**
* Add all fragments, mutation options, and query options defined in the object.
*
* Example:
* add({
* fragments: {
* CreateCommentResponse: gql`
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
* [...]
* }`,
* },
* mutations: {
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* PostComment: ({variables, state}) => ({
* optimisticResponse: {
* [...]
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
* },
* queries: {
* EmbedQuery: {
* reducer: (previousResult, action, variables) => {
* return previousResult;
* },
* },
* },
* });
*/
export function add(extension) {
Object.keys(extension.fragments || []).forEach((key) => addFragment(key, extension.fragments[key]));
Object.keys(extension.mutations || []).forEach((key) => addMutationOptions(key, extension.mutations[key]));
Object.keys(extension.queries || []).forEach((key) => addQueryOptions(key, extension.queries[key]));
}
/**
* Get a list of mutation options.
*/
export function getMutationOptions(key) {
init();
return mutationOptions[key] || [];
}
/**
* Get a list of query options.
*/
export function getQueryOptions(key) {
init();
return queryOptions[key] || [];
}
/**
* getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource.
* It parses the slot name and the resource and usees the plugin API to assemble
* the fragment document.
*/
function getSlotFragmentDocument(key) {
const match = key.match(/TalkSlot_(.*)_(.*)/);
if (!match) {
return '';
}
const slot = match[1][0].toLowerCase() + match[1].substr(1);
const resource = match[2];
const documents = getSlotFragments(slot, resource);
if (documents.length === 0) {
return '';
}
const names = documents.map((d) => getDefinitionName(d));
const typeName = getTypeName(documents[0]);
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${typeName} {
...${names.join('\n...')}\n
/**
* Add mutation options.
*
* Example:
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* addMutationOptions('PostComment', ({variables, state}) => ({
* optimisticResponse: {
* CreateComment: {
* extra: '',
* },
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
*/
addMutationOptions(key, config) {
if (!(key in this.mutationOptions)) {
this.mutationOptions[key] = [config];
} else {
this.mutationOptions[key].push(config);
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getRegistryFragmentDocument assembles a fragment document using
* all registered fragment under given `key`.
*/
function getRegistryFragmentDocument(key) {
if (!(key in fragments)) {
return '';
}
let documents = fragments[key].documents;
let fields = `...${fragments[key].names.join('\n...')}\n`;
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${fragments[key].type} {
${fields}
/**
* Add query options.
*
* Example:
* addQueryOptions('EmbedQuery', {
* reducer: (previousResult, action, variables) => previousResult,
* });
*/
addQueryOptions(key, config) {
if (!(key in this.queryOptions)) {
this.queryOptions[key] = [config];
} else {
this.queryOptions[key].push(config);
}
`;
return mergeDocuments([main, ...documents]);
}
}
/**
* getFragmentDocument returns a fragment that assembles all registered
* fragments under given `key` or if `key` refers to Slot fragments it will
* return the slot fragments specified by this key.
*/
export function getFragmentDocument(key) {
init();
return getRegistryFragmentDocument(key) || getSlotFragmentDocument(key);
}
/**
* Add all fragments, mutation options, and query options defined in the object.
*
* Example:
* add({
* fragments: {
* CreateCommentResponse: gql`
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
* [...]
* }`,
* },
* mutations: {
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* PostComment: ({variables, state}) => ({
* optimisticResponse: {
* [...]
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
* },
* queries: {
* EmbedQuery: {
* reducer: (previousResult, action, variables) => {
* return previousResult;
* },
* },
* },
* });
*/
add(extension) {
Object.keys(extension.fragments || []).forEach((key) => this.addFragment(key, extension.fragments[key]));
Object.keys(extension.mutations || []).forEach((key) => this.addMutationOptions(key, extension.mutations[key]));
Object.keys(extension.queries || []).forEach((key) => this.addQueryOptions(key, extension.queries[key]));
}
// The fragments and configs are lazily loaded to allow circular dependencies to work.
// TODO: We might want to change this to an explicit add after we have lazy Queries and Mutations.
let initialized = false;
/**
* Get a list of mutation options.
*/
getMutationOptions(key) {
return this.mutationOptions[key] || [];
}
function init() {
if (initialized) { return; }
initialized = true;
/**
* Get a list of query options.
*/
getQueryOptions(key) {
return this.queryOptions[key] || [];
}
// Add fragments from framework.
[globalFragments].forEach((map) =>
Object.keys(map).forEach((key) => addFragment(key, map[key]))
);
// Add configs from plugins.
getGraphQLExtensions().forEach((ext) => add(ext));
}
/**
* resolveFragments finds fragment spread names and attachs
* the related fragment document to the given root document.
*/
export function resolveFragments(document) {
if (document.loc.source) {
// Remember keys that we have already resolved.
const resolvedKeys = [];
// Spreads from slots that are empty and need to be removed.
// (works around the issue that we don't know the resource type
// if we don't have a fragment)
const spreadsToBeRemoved = [];
// fragments to be attached.
const subFragments = [];
// body contains the final result.
let body = document.loc.source.body;
let done = false;
while (!done) {
done = true;
const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || [];
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
.filter((key) => resolvedKeys.indexOf(key) === -1)
.forEach((key) => {
const doc = getFragmentDocument(key);
if (doc) {
subFragments.push(doc);
// We found a new fragment, so we are not done yet.
done = false;
} else if(key.startsWith('TalkSlot_')) {
spreadsToBeRemoved.push(key);
}
resolvedKeys.push(key);
});
body = mergeDocuments([body, ...subFragments]).loc.source.body;
/**
* getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource.
* It parses the slot name and the resource and usees the plugin API to assemble
* the fragment document.
*/
getSlotFragmentDocument(key) {
const match = key.match(/TalkSlot_(.*)_(.*)/);
if (!match) {
return '';
}
spreadsToBeRemoved.forEach((key) => {
const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g');
body = body.replace(regex, '');
});
const slot = match[1][0].toLowerCase() + match[1].substr(1);
const resource = match[2];
const documents = this.getSlotFragments(slot, resource);
return gql`${body}`;
} else {
console.warn('Can only resolve fragments from documents definied using the gql tag.');
if (documents.length === 0) {
return '';
}
const names = documents.map((d) => getDefinitionName(d));
const typeName = getTypeName(documents[0]);
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${typeName} {
...${names.join('\n...')}\n
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getRegistryFragmentDocument assembles a fragment document using
* all registered fragment under given `key`.
*/
getRegistryFragmentDocument(key) {
if (!(key in this.fragments)) {
return '';
}
let documents = this.fragments[key].documents;
let fields = `...${this.fragments[key].names.join('\n...')}\n`;
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${this.fragments[key].type} {
${fields}
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getFragmentDocument returns a fragment that assembles all registered
* fragments under given `key` or if `key` refers to Slot fragments it will
* return the slot fragments specified by this key.
*/
getFragmentDocument(key) {
return this.getRegistryFragmentDocument(key) || this.getSlotFragmentDocument(key);
}
/**
* resolveFragments finds fragment spread names and attachs
* the related fragment document to the given root document.
*/
resolveFragments(document) {
if (document.loc.source) {
// Remember keys that we have already resolved.
const resolvedKeys = [];
// Spreads from slots that are empty and need to be removed.
// (works around the issue that we don't know the resource type
// if we don't have a fragment)
const spreadsToBeRemoved = [];
// fragments to be attached.
const subFragments = [];
// body contains the final result.
let body = document.loc.source.body;
let done = false;
while (!done) {
done = true;
const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || [];
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
.filter((key) => resolvedKeys.indexOf(key) === -1)
.forEach((key) => {
const doc = this.getFragmentDocument(key);
if (doc) {
subFragments.push(doc);
// We found a new fragment, so we are not done yet.
done = false;
} else if(key.startsWith('TalkSlot_')) {
spreadsToBeRemoved.push(key);
}
resolvedKeys.push(key);
});
body = mergeDocuments([body, ...subFragments]).loc.source.body;
}
spreadsToBeRemoved.forEach((key) => {
const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g');
body = body.replace(regex, '');
});
return gql`${body}`;
} else {
console.warn('Can only resolve fragments from documents definied using the gql tag.');
}
return document;
}
return document;
}
/**
* createGraphQLRegistry
* @param {Function} getSlotFragments A callback with signature `(slot, part) => [documents]` to retrieve slot fragments.
* @return {Object} graphql registry
*/
export function createGraphQLRegistry(getSlotFragments) {
return new GraphQLRegistry(getSlotFragments);
}
@@ -0,0 +1,17 @@
import {browserHistory} from 'react-router';
import {useBasename} from 'history';
/**
* createHistory returns the history service for react router
* @param {string} basename base path of the url
* @return {Object} histor service
*/
export function createHistory(basename) {
if (!basename) {
return browserHistory;
}
return useBasename(() => browserHistory)({
basename
});
}
@@ -0,0 +1,18 @@
/**
* createNotificationService returns a notification services based on pym.
* @param {Object} pym
* @return {Object} notification service
*/
export function createNotificationService(pym) {
return {
success(msg) {
pym.sendMessage('coral-alert', `success|${msg}`);
},
error(msg) {
pym.sendMessage('coral-alert', `error|${msg}`);
},
info(msg) {
pym.sendMessage('coral-alert', `info|${msg}`);
},
};
}
+184
View File
@@ -0,0 +1,184 @@
import React from 'react';
import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
import merge from 'lodash/merge';
import flattenDeep from 'lodash/flattenDeep';
import isEmpty from 'lodash/isEmpty';
import flatten from 'lodash/flatten';
import mapValues from 'lodash/mapValues';
import {getDisplayName} from 'coral-framework/helpers/hoc';
import camelize from '../helpers/camelize';
import uuid from 'uuid/v4';
// This is returned for pluginConfig when it is empty.
const emptyConfig = {};
// Memoize the warnings so we only show them once.
const memoizedWarnings = [];
// withWarnings decorates the props of queryData with a proxy that
// prints a warning when accessing deeper props.
function withWarnings(component, queryData) {
if (process.env.NODE_ENV !== 'production' && window.Proxy) {
// Show warnings when accessing queryData only when not in production.
return mapValues(queryData, (value, key) => {
// Keep null values..
if (!queryData[key]) {
return queryData[key];
}
return new Proxy(queryData[key], {
get(target, name) {
// Detect access from React DevTools and ignore those.
const error = new Error();
const accessFromDevTools = ['backend.js', 'dehydrate']
.every((keyword) => error.stack && error.stack.includes(keyword));
// Only care about the components defined in the plugins.
if (component.talkPluginName && !accessFromDevTools) {
const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`;
if (memoizedWarnings.indexOf(warning) === -1) {
console.warn(warning);
memoizedWarnings.push(warning);
}
}
return queryData[key][name];
}
});
});
}
return queryData;
}
function addMetaDataToSlotComponents(plugins) {
// Add talkPluginName to Slot Components.
plugins.forEach((plugin) => {
const slots = plugin.module.slots;
slots && Object.keys(slots).forEach((slot) => {
slots[slot].forEach((component) => {
// Attach plugin name to the component
component.talkPluginName = plugin.name;
// Attach uuid to the component
component.talkUuid = uuid();
});
});
});
}
class PluginsService {
constructor(plugins) {
this.plugins = plugins;
addMetaDataToSlotComponents(plugins);
}
getSlotComponents(slot, reduxState, props = {}, queryData = {}) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return flatten(this.plugins
// Filter out components that have slots and have been disabled in `plugin_config`
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components))
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot])
)
.filter((component) => {
if(!component.isExcluded) {
return true;
}
let resolvedProps = this.getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
return !component.isExcluded(resolvedProps);
});
}
isSlotEmpty(slot, reduxState, props = {}, queryData = {}) {
return this.getSlotComponents(slot, reduxState, props, queryData).length === 0;
}
/**
* getSlotComponentProps calculate the props we would pass to the slot component.
* query datas are only passed to the component if it is defined in `component.fragments`.
*/
getSlotComponentProps(component, reduxState, props, queryData) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return {
...props,
config: pluginConfig,
...(
component.fragments
? pick(queryData, Object.keys(component.fragments))
: withWarnings(component, queryData)
)
};
}
/**
* Returns React Elements for given slot.
*/
getSlotElements(slot, reduxState, props = {}, queryData = {}) {
return this.getSlotComponents(slot, reduxState, props, queryData)
.map((component, i) => {
return React.createElement(component, {key: i, ...this.getSlotComponentProps(component, reduxState, props, queryData)});
});
}
getSlotFragments(slot, part) {
const components = uniq(flattenDeep(this.plugins
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
.map((o) => o.module.slots[slot])
));
const documents = components
.map((c) => c.fragments)
.filter((fragments) => fragments && fragments[part])
.reduce((res, fragments) => {
res.push(fragments[part]);
return res;
}, []);
return documents;
}
getGraphQLExtensions() {
return this.plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => !isEmpty(o));
}
getModQueueConfigs() {
return merge(...this.plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
getTranslations() {
return this.plugins
.map((o) => o.module.translations)
.filter((o) => o);
}
getReducers() {
return merge(
...this.plugins
.filter((o) => o.module.reducer)
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
);
}
}
/**
* createPluginsService returns a plugins service.
* @param {Array} plugins config as returned from importing `pluginsConfig`
* @return {Object} plugins service
*/
export function createPluginsService(pluginsConfig) {
return new PluginsService(pluginsConfig);
}
+69
View File
@@ -0,0 +1,69 @@
import merge from 'lodash/merge';
const buildOptions = (inputOptions = {}) => {
const defaultOptions = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin'
};
const options = merge({}, defaultOptions, inputOptions);
if (options.method.toLowerCase() !== 'get') {
options.body = JSON.stringify(options.body);
}
return options;
};
const handleResp = (res) => {
if (res.status > 399) {
return res.json().then((err) => {
let message = err.message || res.status;
const error = new Error();
if (err.error && err.error.metadata && err.error.metadata.message) {
error.metadata = err.error.metadata.message;
}
if (err.error && err.error.translation_key) {
error.translation_key = err.error.translation_key;
}
error.message = message;
error.status = res.status;
throw error;
});
} else if (res.status === 204) {
return res.text();
} else {
return res.json();
}
};
/**
* createRestClient setups and returns a Rest Client
* @param {Object} options configuration
* @param {string} options.uri uri of the rest server
* @param {string|function} [options.token] auth token
* @return {Object} rest client
*/
export function createRestClient(options) {
const {token, uri} = options;
const client = (path, options) => {
const authToken = typeof token === 'function' ? token() : token;
let opts = options;
if (authToken) {
opts = merge({}, options, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
}
return fetch(`${uri}${path}`, buildOptions(opts)).then(handleResp);
};
client.uri = uri;
return client;
}
@@ -0,0 +1,43 @@
function getStorage(type) {
let storage = window[type], x = '__storage_test__';
try {
storage.setItem(x, x);
storage.removeItem(x);
} catch (e) {
const ignore = (
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0
);
if (!ignore) {
console.warning(e); // eslint-disable-line
return null;
}
}
return storage;
}
/**
* createStorage returns a localStorage wrapper if available
* @return {Object} localStorage wrapper
*/
export function createStorage() {
return getStorage('localStorage');
}
+12 -53
View File
@@ -1,68 +1,27 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {getClient} from './client';
let listeners = [];
export function injectReducers(reducers) {
const store = getStore();
store.coralReducers = {...store.coralReducers, ...reducers};
store.replaceReducer(combineReducers(store.coralReducers));
}
import {createStore as reduxCreateStore, combineReducers, applyMiddleware, compose} from 'redux';
/**
* Add a action listener to the redux store.
* The action is passed as the first argument to the callback.
* createStore creates a Redux Store
* @param {Object} reducers addtional reducers
* @param {Array} [middlewares] additional middlewares
* @return {Object} redux store
*/
export function addListener(cb) {
listeners.push(cb);
}
export function getStore() {
if (window.coralStore) {
return window.coralStore;
}
const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
const customListener = () => (next) => (action) => {
listeners.forEach((cb) => {cb(action);});
return next(action);
};
const middlewares = [
export function createStore(reducers, middlewares = []) {
const enhancers = [
applyMiddleware(
getClient().middleware(),
thunk,
apolloErrorReporter,
customListener,
...middlewares,
),
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
enhancers.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: getClient().reducer()
};
window.coralStore = createStore(
combineReducers(coralReducers),
return reduxCreateStore(
combineReducers(reducers),
{},
compose(...middlewares)
compose(...enhancers)
);
window.coralStore.coralReducers = coralReducers;
return window.coralStore;
}
@@ -1,37 +0,0 @@
import {createNetworkInterface} from 'apollo-client';
import {getAuthToken} from '../helpers/request';
import {BASE_PATH} from 'coral-framework/constants/url';
//==============================================================================
// NETWORK INTERFACE
//==============================================================================
const networkInterface = createNetworkInterface({
uri: `${BASE_PATH}api/v1/graph/ql`,
opts: {
credentials: 'same-origin'
}
});
//==============================================================================
// MIDDLEWARES
//==============================================================================
networkInterface.use([{
applyMiddleware(req, next) {
if (!req.options.headers) {
req.options.headers = {}; // Create the header object if needed.
}
let authToken = getAuthToken();
if (authToken) {
req.options.headers['authorization'] = `Bearer ${authToken}`;
}
next();
}
}]);
export {
networkInterface
};
+6
View File
@@ -147,6 +147,12 @@ export function forEachError(error, callback) {
});
}
export function getErrorMessages(error) {
const result = [];
forEachError(error, ({msg}) => result.push(msg));
return result;
}
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
@@ -11,7 +11,10 @@ import NotLoggedIn from '../components/NotLoggedIn';
import IgnoredUsers from '../components/IgnoredUsers';
import {Spinner} from 'coral-ui';
import CommentHistory from 'talk-plugin-history/CommentHistory';
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
// TODO: Auth logic needs refactoring.
import {showSignInDialog, checkLogin} from 'coral-embed-stream/src/actions/auth';
import {insertCommentsSorted} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
+8 -8
View File
@@ -12,11 +12,11 @@ export const name = 'talk-plugin-commentbox';
// Given a newly posted comment's status, show a notification to the user
// if needed
export const notifyForNewCommentStatus = (addNotification, status) => {
export const notifyForNewCommentStatus = (notify, status) => {
if (status === 'REJECTED') {
addNotification('error', t('comment_box.comment_post_banned_word'));
notify('error', t('comment_box.comment_post_banned_word'));
} else if (status === 'PREMOD') {
addNotification('success', t('comment_box.comment_post_notif_premod'));
notify('success', t('comment_box.comment_post_notif_premod'));
}
};
@@ -45,12 +45,12 @@ class CommentBox extends React.Component {
postComment,
assetId,
parentId,
addNotification,
notify,
currentUser,
} = this.props;
if (!can(currentUser, 'INTERACT_WITH_COMMUNITY')) {
addNotification('error', t('error.NOT_AUTHORIZED'));
notify('error', t('error.NOT_AUTHORIZED'));
return;
}
@@ -73,7 +73,7 @@ class CommentBox extends React.Component {
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach((hook) => hook(data));
notifyForNewCommentStatus(addNotification, postedComment.status);
notifyForNewCommentStatus(notify, postedComment.status);
if (commentPostedHandler) {
commentPostedHandler();
@@ -81,7 +81,7 @@ class CommentBox extends React.Component {
})
.catch((err) => {
this.setState({loadingState: 'error'});
forEachError(err, ({msg}) => addNotification('error', msg));
forEachError(err, ({msg}) => notify('error', msg));
});
}
@@ -186,7 +186,7 @@ CommentBox.propTypes = {
currentUser: PropTypes.object.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
const mapStateToProps = ({commentBox}) => ({commentBox});
@@ -42,7 +42,7 @@ export default class FlagButton extends Component {
this.setState({showMenu: true});
}
} else {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
this.props.notify('error', t('error.NOT_AUTHORIZED'));
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ class CommentHistory extends React.Component {
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
forEachError(error, ({msg}) => {this.props.notify('error', msg);});
});
}
+3 -3
View File
@@ -19,7 +19,7 @@ class ReplyBox extends Component {
postComment,
assetId,
currentUser,
addNotification,
notify,
parentId,
commentPostedHandler,
maxCharCount,
@@ -32,7 +32,7 @@ class ReplyBox extends Component {
commentPostedHandler={commentPostedHandler}
parentId={parentId}
onCancel={this.cancelReply}
addNotification={addNotification}
notify={notify}
currentUser={currentUser}
assetId={assetId}
postComment={postComment}
@@ -47,7 +47,7 @@ ReplyBox.propTypes = {
setActiveReplyBox: PropTypes.func.isRequired,
commentPostedHandler: PropTypes.func,
parentId: PropTypes.string,
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
assetId: PropTypes.string.isRequired
};
+3 -1
View File
@@ -7,7 +7,6 @@ import {getDisplayName} from 'coral-framework/helpers/hoc';
import {compose, gql} from 'react-apollo';
import withFragments from 'coral-framework/hocs/withFragments';
import withMutation from 'coral-framework/hocs/withMutation';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {addNotification} from 'coral-framework/actions/notification';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
@@ -15,6 +14,9 @@ import hoistStatics from 'recompose/hoistStatics';
import * as PropTypes from 'prop-types';
import {getDefinitionName} from '../utils';
// TODO: Auth logic needs refactoring.
import {showSignInDialog} from 'coral-embed-stream/src/actions/auth';
/*
* Disable false-positive warning below, as it doesn't work well with how we currently
* assemble the queries.
-6
View File
@@ -1,9 +1,3 @@
export {t, timeago} from 'coral-framework/services/i18n';
export {can} from 'coral-framework/services/perms';
import {isSlotEmpty as ise} from 'coral-framework/helpers/plugins';
// @TODO: Deprecated.
export function isSlotEmpty(...args) {
console.warn('A plugin is using `isSlotEmpty` which has been deprecated, please port to the new API using the `IfSlotIsEmpty` and `IfSlotIsNotEmpty` components.');
return ise(...args);
}
+1
View File
@@ -3,5 +3,6 @@ export {
insertCommentsSorted,
getSlotFragmentSpreads,
forEachError,
getErrorMessages,
getDefinitionName,
} from 'coral-framework/utils';
@@ -13,7 +13,7 @@ import {
invalidForm,
validForm,
createUsername
} from 'coral-framework/actions/auth';
} from 'coral-embed-stream/src/actions/auth';
class ChangeUsernameContainer extends React.Component {
constructor(props) {
@@ -2,7 +2,7 @@ import React from 'react';
import {Button} from 'plugin-api/beta/client/components/ui';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {showSignInDialog} from 'coral-embed-stream/src/actions/auth';
import t from 'coral-framework/services/i18n';
const SignInButton = ({loggedIn, showSignInDialog}) => (
@@ -18,7 +18,7 @@ import {
facebookCallback,
invalidForm,
validForm,
} from 'coral-framework/actions/auth';
} from 'coral-embed-stream/src/actions/auth';
class SignInContainer extends React.Component {
constructor(props) {
@@ -3,7 +3,7 @@ import styles from './styles.css';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import t from 'coral-framework/services/i18n';
import {logout} from 'coral-framework/actions/auth';
import {logout} from 'coral-embed-stream/src/actions/auth';
const UserBox = ({loggedIn, user, logout, onShowProfile}) => (
<div>
@@ -21,14 +21,14 @@ class ModSubscription extends React.Component {
assetId: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentFeatured: {user, comment}}}}) => {
const notify = this.props.user.id === user.id
const notifyText = this.props.user.id === user.id
? ''
: t(
'talk-plugin-featured-comments.notify_featured',
user.username,
prepareNotificationText(comment.body),
);
return this.props.handleCommentChange(prev, comment, notify);
return this.props.handleCommentChange(prev, comment, notifyText);
},
},
{