Merge branch 'master' into permalink-enh

This commit is contained in:
Kiwi
2018-03-01 20:13:04 +01:00
committed by GitHub
27 changed files with 623 additions and 42 deletions
+21 -6
View File
@@ -8,6 +8,7 @@ job_defaults: &job_defaults
integration_environment: &integration_environment
NODE_ENV: test
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
E2E_MAX_RETRIES: 3
# integration_job runs the integration tests and saves the test results.
integration_job: &integration_job
@@ -29,6 +30,7 @@ integration_job: &integration_job
name: Run the integration tests
command: bash .circleci/e2e.sh
- store_test_results:
when: always
path: /tmp/circleci-test-results
version: 2
@@ -104,13 +106,19 @@ jobs:
name: Setup the test results directory
command: mkdir -p /tmp/circleci-test-results
- run:
name: Run the unit tests
command: yarn test
name: Run the client unit tests
command: yarn test:client --ci
environment:
MOCHA_FILE: /tmp/circleci-test-results/junit/test-results.xml
JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml
JEST_REPORTER: jest-junit
- run:
name: Run the server unit tests
command: yarn test:server
environment:
MOCHA_FILE: /tmp/circleci-test-results/mocha/test-results.xml
MOCHA_REPORTER: mocha-junit-reporter
NODE_ENV: test
- store_test_results:
when: always
path: /tmp/circleci-test-results
# test_integration_chrome_local will run the integration tests locally with
@@ -164,6 +172,8 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: ie
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# test_integration_safari will run the integration tests with safari in
# browserstack.
@@ -173,6 +183,8 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: safari
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# deploy will deploy the application as a docker image.
deploy:
@@ -182,7 +194,7 @@ jobs:
- setup_remote_docker
- run:
name: Deploy the code
command: bash ./scripts/docker.sh
command: bash ./scripts/docker.sh deploy
# filter_deploy will add the filters for a deploy job in a workflow to make it
# only execute on a deploy related job.
@@ -274,4 +286,7 @@ workflows:
- test_unit
- test_integration_chrome
- test_integration_firefox
- test_integration_edge
- test_integration_edge
# TODO: uncomment when more reliable
# - test_integration_ie
# - test_integration_safari
+2
View File
@@ -40,7 +40,9 @@ plugins/*
!plugins/talk-plugin-mod
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-notifications
!plugins/talk-plugin-notifications-category-featured
!plugins/talk-plugin-notifications-category-reply
!plugins/talk-plugin-notifications-category-staff
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
+2 -1
View File
@@ -143,7 +143,7 @@ async function reconcileRemotePlugins({ dryRun, upgradeRemote }) {
if (fetchable.length > 0) {
console.log(
`$ yarn add --ignore-scripts ${fetchable.map(
`$ yarn add --ignore-scripts --ignore-workspace-root-check ${fetchable.map(
({ name, version }) => `${name}@${version}`.cyan
)}`
);
@@ -152,6 +152,7 @@ async function reconcileRemotePlugins({ dryRun, upgradeRemote }) {
let args = [
'add',
'--ignore-scripts',
'--ignore-workspace-root-check',
...fetchable.map(({ name, version }) => `${name}@${version}`),
];
+2 -1
View File
@@ -72,7 +72,8 @@ class Slot extends React.Component {
} = this.props;
const { plugins } = this.context;
let children = this.getChildren();
const pluginConfig = get(reduxState, 'config.pluginConfig') || emptyConfig;
const pluginConfig =
get(reduxState, 'config.plugins_config') || emptyConfig;
if (children.length === 0 && DefaultComponent) {
const props = plugins.getSlotComponentProps(
DefaultComponent,
+8
View File
@@ -149,3 +149,11 @@ Source: [plugins/talk-plugin-notifications-category-reply](https://github.com/co
Replies made to each user will trigger an email to be sent with the notification
details if enabled.
### talk-plugin-notifications-category-featured
{:.param}
Source: [plugins/talk-plugin-notifications-category-featured](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-featured){:target="_blank"}
When a comment is featured (via the `talk-plugin-featured-comments` plugin), the
user will receive a notification email.
+2
View File
@@ -27,6 +27,8 @@ module.exports = {
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
+1
View File
@@ -216,6 +216,7 @@
"identity-obj-proxy": "^3.0.0",
"ip": "^1.1.5",
"jest": "^21.2.1",
"jest-junit": "^3.6.0",
"lint-staged": "^7.0.0",
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Toggle from 'talk-plugin-notifications/client/components/Toggle';
import { t } from 'plugin-api/beta/client/services';
import { withFragments } from 'plugin-api/beta/client/hocs';
class ToggleContainer extends React.Component {
constructor(props) {
super(props);
props.setTurnOffInputFragment({ onFeatured: false });
if (this.getOnFeaturedSetting()) {
props.indicateOn();
}
}
componentWillReceiveProps(nextProps) {
const prevSetting = this.getOnFeaturedSetting(this.props);
const nextSetting = this.getOnFeaturedSetting(nextProps);
if (prevSetting && !nextSetting) {
nextProps.indicateOff();
} else if (!prevSetting && nextSetting) {
nextProps.indicateOn();
}
}
getOnFeaturedSetting = (props = this.props) =>
props.root.me.notificationSettings.onFeatured;
toggle = () => {
this.props.updateNotificationSettings({
onFeatured: !this.getOnFeaturedSetting(),
});
};
render() {
return (
<Toggle checked={this.getOnFeaturedSetting()} onChange={this.toggle}>
{t('talk-plugin-notifications-category-featured.toggle_description')}
</Toggle>
);
}
}
ToggleContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
indicateOn: PropTypes.func.isRequired,
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryFeatured_Toggle_root on RootQuery {
me {
notificationSettings {
onFeatured
}
}
}
`,
})
);
export default enhance(ToggleContainer);
@@ -0,0 +1,33 @@
import { gql } from 'react-apollo';
export default {
mutations: {
UpdateNotificationSettings: ({
variables: { input },
state: { auth: { user: { id } } },
}) => ({
update: proxy => {
if (input.onFeatured === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryFeatured_User_Fragment on User {
notificationSettings {
onFeatured
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
onFeatured: input.onFeatured,
},
};
proxy.writeFragment({ fragment, id: fragmentId, data });
},
}),
},
};
@@ -0,0 +1,11 @@
import Toggle from './containers/Toggle';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
notificationSettings: [Toggle],
},
translations,
...graphql,
};
@@ -0,0 +1,3 @@
en:
talk-plugin-notifications-category-featured:
toggle_description: My comment is featured
@@ -0,0 +1,118 @@
const { graphql } = require('graphql');
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, { comment }) => {
const { connectors: { graph: { schema } } } = ctx;
// Check to see if this is a reply to an existing comment.
const commentID = get(comment, 'id', null);
if (commentID === null) {
ctx.log.debug('could not get comment id');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onFeatured
}
}
}
}
`,
{},
ctx,
{ comment_id: commentID }
);
if (reply.errors) {
ctx.log.error({ err: reply.errors }, 'could not query for author metadata');
return;
}
// Check if the user has notifications enabled.
const enabled = get(
reply,
'data.comment.user.notificationSettings.onFeatured',
false
);
if (!enabled) {
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('could not get comment user id');
return;
}
// The user does have notifications for featured comments enabled, queue the
// notification to be sent.
return { userID, date: comment.created_at, context: comment.id };
};
const hydrate = async (ctx, category, context) => {
const { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
throw reply.errors;
}
const comment = get(reply, 'data.comment');
const headline = get(comment, 'asset.title', null);
const assetURL = get(comment, 'asset.url', null);
const permalink = `${assetURL}?commentId=${comment.id}`;
return [headline, permalink];
};
const handler = {
handle,
category: 'featured',
event: 'commentFeatured',
hydrate,
};
module.exports = {
typeDefs: `
type NotificationSettings {
onFeatured: Boolean!
}
input NotificationSettingsInput {
onFeatured: Boolean
}
`,
resolvers: {
NotificationSettings: {
// onFeatured returns false by default if not specified.
onFeatured: settings => get(settings, 'onFeatured', false),
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
};
@@ -0,0 +1,6 @@
en:
talk-plugin-notifications:
categories:
featured:
subject: "One of your comments was featured on [{0}]"
body: "{0}\nA member of our team has selected this comment to be featured for other readers: {1}"
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -2,5 +2,5 @@ en:
talk-plugin-notifications:
categories:
reply:
subject: "Some has replied to your comment on [{0}]"
subject: "Someone has replied to your comment on {0}"
body: "{0}\n{1} replied to your comment: {2}"
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Toggle from 'talk-plugin-notifications/client/components/Toggle';
import { t } from 'plugin-api/beta/client/services';
import { withFragments } from 'plugin-api/beta/client/hocs';
class ToggleContainer extends React.Component {
constructor(props) {
super(props);
props.setTurnOffInputFragment({ onStaffReply: false });
if (this.getOnReplySetting()) {
props.indicateOn();
}
}
componentWillReceiveProps(nextProps) {
const prevSetting = this.getOnReplySetting(this.props);
const nextSetting = this.getOnReplySetting(nextProps);
if (prevSetting && !nextSetting) {
nextProps.indicateOff();
} else if (!prevSetting && nextSetting) {
nextProps.indicateOn();
}
}
getOnReplySetting = (props = this.props) =>
props.root.me.notificationSettings.onStaffReply;
toggle = () => {
this.props.updateNotificationSettings({
onStaffReply: !this.getOnReplySetting(),
});
};
render() {
return (
<Toggle checked={this.getOnReplySetting()} onChange={this.toggle}>
{t('talk-plugin-notifications-category-staff.toggle_description')}
</Toggle>
);
}
}
ToggleContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
indicateOn: PropTypes.func.isRequired,
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryStaffReply_User_Fragment on RootQuery {
me {
notificationSettings {
onStaffReply
}
}
}
`,
})
);
export default enhance(ToggleContainer);
@@ -0,0 +1,33 @@
import { gql } from 'react-apollo';
export default {
mutations: {
UpdateNotificationSettings: ({
variables: { input },
state: { auth: { user: { id } } },
}) => ({
update: proxy => {
if (input.onStaffReply === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryStaffReply_User_Fragment on User {
notificationSettings {
onStaffReply
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
onStaffReply: input.onStaffReply,
},
};
proxy.writeFragment({ fragment, id: fragmentId, data });
},
}),
},
};
@@ -0,0 +1,11 @@
import Toggle from './containers/Toggle';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
notificationSettings: [Toggle],
},
translations,
...graphql,
};
@@ -0,0 +1,3 @@
en:
talk-plugin-notifications-category-staff:
toggle_description: A staff member replies to my comment
@@ -0,0 +1,151 @@
const { graphql } = require('graphql');
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
const { connectors: { graph: { schema } } } = ctx;
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
ctx.log.debug('could not get parent comment id');
return;
}
const authorID = get(comment, 'author_id', null);
if (authorID === null) {
ctx.log.error('could not get author id');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
`
query GetAuthorUserMetadata($comment_id: ID!, $author_id: ID!) {
author: user(id: $author_id) {
role
}
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onStaffReply
}
}
}
}
`,
{},
ctx,
{ comment_id: parentID, author_id: authorID }
);
if (reply.errors) {
ctx.log.error({ err: reply.errors }, 'could not query for author metadata');
return;
}
// Check if the user has notifications enabled.
const enabled = get(
reply,
'data.comment.user.notificationSettings.onStaffReply',
false
);
if (!enabled) {
ctx.log.debug('onStaffReply is false, will not send the notification');
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('could not get parent comment user id');
return;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (userID === authorID) {
ctx.log.debug('user id of parent comment is the same as the new comment');
return;
}
// Check to see that this comment was indeed from a staff member.
const role = get(reply, 'data.author.role');
if (!['ADMIN', 'MODERATOR', 'STAFF'].includes(role)) {
ctx.log.debug({ role }, 'reply author is not a staff member');
return;
}
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return { userID, date: comment.created_at, context: comment.id };
};
const hydrate = async (ctx, category, context) => {
const { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
user {
username
}
}
settings {
organizationName
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
throw reply.errors;
}
const comment = get(reply, 'data.comment');
const headline = get(comment, 'asset.title', null);
const replier = get(comment, 'user.username', null);
const assetURL = get(comment, 'asset.url', null);
const permalink = `${assetURL}?commentId=${comment.id}`;
const organizationName = get(reply, 'data.settings.organizationName', null);
return [headline, replier, organizationName, permalink];
};
const handler = {
handle,
category: 'staff',
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
};
module.exports = {
typeDefs: `
type NotificationSettings {
onStaffReply: Boolean!
}
input NotificationSettingsInput {
onStaffReply: Boolean
}
`,
resolvers: {
NotificationSettings: {
// onStaffReply returns false by default if not specified.
onStaffReply: settings => get(settings, 'onStaffReply', false),
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
};
@@ -0,0 +1,6 @@
en:
talk-plugin-notifications:
categories:
staff:
subject: "Someone at {0} has replied to your comment"
body: "{0}\n{1} works for {2} and has replied to your comment: {3}"
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -23,9 +23,9 @@ class SettingsContainer extends React.Component {
hasNotifications: this.state.hasNotifications.filter(i => i !== plugin),
});
setTurnOffInputFragment = fragment =>
this.setState({
turnOffInput: { ...this.state.turnOffInput, ...fragment },
});
this.setState(state => ({
turnOffInput: { ...state.turnOffInput, ...fragment },
}));
turnOffAll = () => {
this.props.updateNotificationSettings(this.state.turnOffInput);
@@ -1,8 +1,43 @@
const { groupBy, forEach } = require('lodash');
const { groupBy, forEach, property } = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const uuid = require('uuid/v4');
const { UNSUBSCRIBE_SUBJECT } = require('./config');
// handleHandlers will call the handle method on each handler to determine if a
// notification should be sent for it.
const handleHandlers = (ctx, handlers, ...args) =>
Promise.all(
handlers.map(async handler => {
// Grab the handler reference.
const { handle, category, event } = handler;
try {
// Attempt to create a notification out of it.
const notification = await handle(ctx, ...args);
if (!notification) {
ctx.log.debug('no notification deemed by event handler');
return;
}
// Send the notification back.
ctx.log.debug({ category, event }, 'notification detected for event');
return { handler, notification };
} catch (err) {
ctx.log.error({ err }, 'could not handle the event');
return;
}
})
);
// filterSuperseded will filter all the possible notifications and only send
// those notifications that are not superseded by another type of notification.
const filterSuperseded = ({ handler: { category } }, index, notifications) =>
!notifications.some(({ handler: { supersedesCategories = [] } }) =>
supersedesCategories.some(
supersededCategory => supersededCategory === category
)
);
class NotificationManager {
constructor(context) {
this.context = context;
@@ -44,33 +79,28 @@ class NotificationManager {
* @param {Object} handler a notification handler
*/
handle(handlers) {
return async (...args) =>
Promise.all(
handlers.map(async handler => {
// Grab the handler reference.
const { handle } = handler;
return async (...args) => {
// Create a system context to send down.
const ctx = this.context.forSystem();
// Create a system context to send down.
const ctx = this.context.forSystem();
// Get all the notifications to load.
let notifications = await handleHandlers(ctx, handlers, ...args);
try {
// Attempt to create a notification out of it.
const notification = await handle(ctx, ...args);
if (!notification) {
return;
}
// Only let handlers past that have a notification to send.
notifications = notifications.filter(property('notification'));
// Extract the notification details.
const { userID, date, context } = notification;
// Check to see if some of the other notifications that are queued
// had this notification superseded.
notifications = notifications.filter(filterSuperseded);
// Send the notification.
return this.send(ctx, userID, date, handler, context);
} catch (err) {
ctx.log.error({ err }, 'could not handle the event');
return;
}
})
// Send the remaining notifications.
return Promise.all(
notifications.map(
({ handler, notification: { userID, date, context } }) =>
this.send(ctx, userID, date, handler, context)
)
);
};
}
async send(ctx, userID, date, handler, context) {
+9 -1
View File
@@ -5399,6 +5399,14 @@ jest-jasmine2@^22.4.2:
jest-util "^22.4.1"
source-map-support "^0.5.0"
jest-junit@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/jest-junit/-/jest-junit-3.6.0.tgz#f4c4358e5286364a4324dc14abddd526aadfbd38"
dependencies:
mkdirp "^0.5.1"
strip-ansi "^4.0.0"
xml "^1.0.1"
jest-matcher-utils@^21.2.1:
version "21.2.1"
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-21.2.1.tgz#72c826eaba41a093ac2b4565f865eb8475de0f64"
@@ -10987,7 +10995,7 @@ xml-name-validator@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
xml@^1.0.0:
xml@^1.0.0, xml@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5"