From bfdce21a66fc66f532f2aa94bb77198f62bc6c79 Mon Sep 17 00:00:00 2001 From: Devin Howard Date: Sat, 11 Feb 2017 19:54:09 -0800 Subject: [PATCH 01/32] fix topic spec --- db/schema.rb | 4 ++-- spec/controllers/topics_controller_spec.rb | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 66bbf9cd..21a2447b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -84,8 +84,8 @@ ActiveRecord::Schema.define(version: 20170209215911) do t.integer "user_id" t.string "followed_type" t.integer "followed_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["followed_type", "followed_id"], name: "index_follows_on_followed_type_and_followed_id", using: :btree t.index ["user_id"], name: "index_follows_on_user_id", using: :btree end diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 96689403..16526835 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -3,12 +3,11 @@ require 'rails_helper' RSpec.describe TopicsController, type: :controller do let(:user) { create(:user) } - let(:topic) { create(:topic, user: user) } + let(:topic) { create(:topic, user: user, updated_by: user) } let(:valid_attributes) { topic.attributes.except('id') } let(:invalid_attributes) { { permission: :invalid_lol } } before :each do - sign_in :user - + sign_in(user) end describe 'POST #create' do From 4ee4aeaad2f5464b97f3af320fa60a62f92ab863 Mon Sep 17 00:00:00 2001 From: Devin Howard Date: Sat, 11 Feb 2017 19:56:07 -0800 Subject: [PATCH 02/32] fix synapse/mapping spec --- app/services/follow_service.rb | 54 ++++++++++---------- spec/controllers/synapses_controller_spec.rb | 5 +- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 22a1786d..9c0693de 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -1,35 +1,35 @@ # frozen_string_literal: true class FollowService - + class << self + def follow(entity, user, reason) - def self.follow(entity, user, reason) + return unless is_tester(user) - return unless is_tester(user) - - follow = Follow.where(followed: entity, user: user).first_or_create - if FollowReason::REASONS.include?(reason) && !follow.follow_reason.read_attribute(reason) - follow.follow_reason.update_attribute(reason, true) - end - end - - def self.unfollow(entity, user) - Follow.where(followed: entity, user: user).destroy_all - end - - def self.remove_reason(entity, user, reason) - return unless FollowReason::REASONS.include?(reason) - follow = Follow.where(followed: entity, user: user).first - if follow - follow.follow_reason.update_attribute(reason, false) - if !follow.follow_reason.has_reason - follow.destroy + follow = Follow.where(followed: entity, user: user).first_or_create + if FollowReason::REASONS.include?(reason) && !follow.follow_reason.read_attribute(reason) + follow.follow_reason.update_attribute(reason, true) end end - end - - protected - - def is_tester(user) - %w(connorturland@gmail.com devin@callysto.com chessscholar@gmail.com solaureum@gmail.com ishanshapiro@gmail.com).include?(user.email) + + def unfollow(entity, user) + Follow.where(followed: entity, user: user).destroy_all + end + + def remove_reason(entity, user, reason) + return unless FollowReason::REASONS.include?(reason) + follow = Follow.where(followed: entity, user: user).first + if follow + follow.follow_reason.update_attribute(reason, false) + if !follow.follow_reason.has_reason + follow.destroy + end + end + end + + protected + + def is_tester(user) + %w(connorturland@gmail.com devin@callysto.com chessscholar@gmail.com solaureum@gmail.com ishanshapiro@gmail.com).include?(user.email) + end end end diff --git a/spec/controllers/synapses_controller_spec.rb b/spec/controllers/synapses_controller_spec.rb index 7abeb2ee..21151ffc 100644 --- a/spec/controllers/synapses_controller_spec.rb +++ b/spec/controllers/synapses_controller_spec.rb @@ -2,11 +2,12 @@ require 'rails_helper' RSpec.describe SynapsesController, type: :controller do - let(:synapse) { create(:synapse) } + let(:user) { create(:user) } + let(:synapse) { create(:synapse, user: user, updated_by: user) } let(:valid_attributes) { synapse.attributes.except('id') } let(:invalid_attributes) { { permission: :invalid_lol } } before :each do - sign_in create(:user) + sign_in(user) end describe 'POST #create' do From 9dbbdf11500d5c8c37061241ce8096b3ffa7fec1 Mon Sep 17 00:00:00 2001 From: Devin Howard Date: Sat, 11 Feb 2017 20:00:42 -0800 Subject: [PATCH 03/32] brakeman csrf warning suppressed :| --- config/brakeman.ignore | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 9e29ff0d..c2491dcd 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,24 +1,24 @@ { "ignored_warnings": [ { - "warning_type": "Cross Site Scripting", - "warning_code": 2, - "fingerprint": "88694dca0bcc2226859746f9ed40cc682d6e5eaec1e73f2be557770a854ede0b", - "message": "Unescaped model attribute", - "file": "app/views/notifications/show.html.erb", - "line": 7, - "link": "http://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "current_user.mailbox.notifications.find_by(:id => params[:id]).body", - "render_path": [{"type":"controller","class":"NotificationsController","method":"show","line":24,"file":"app/controllers/notifications_controller.rb"}], + "warning_type": "Cross-Site Request Forgery", + "warning_code": 7, + "fingerprint": "59d73ce0b791aa7ed532510c780235a8b23f7cd1246dbf9da258e36f5d1e2b0a", + "message": "'protect_from_forgery' should be called in Api::V2::RestfulController", + "file": "app/controllers/api/v2/restful_controller.rb", + "line": 4, + "link": "http://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/", + "code": null, + "render_path": null, "location": { - "type": "template", - "template": "notifications/show" + "type": "controller", + "controller": "Api::V2::RestfulController" }, - "user_input": "current_user.mailbox.notifications", - "confidence": "Weak", - "note": "" + "user_input": null, + "confidence": "High", + "note": "Cookie-based auth is disabled for the API except for the tokens endpoint. We're hoping this is sufficiently secure, because CSRF-forged links might get clicked on another site, but the generated tokens won't go back to the attacker. Also, an attacker would need a token to delete it, which means they've got access at that point anyways. - Devin, Feb 2017" } ], - "updated": "2016-11-29 13:01:34 -0500", - "brakeman_version": "3.4.0" + "updated": "2017-02-11 20:00:09 -0800", + "brakeman_version": "3.4.1" } From 013e3c7f21de34aacf78d0146926ec3866c7704d Mon Sep 17 00:00:00 2001 From: Connor Turland Date: Wed, 15 Feb 2017 23:01:53 -0500 Subject: [PATCH 04/32] follows for maps in the ui for internal testing only still (#1072) * follows for maps in the ui for testers * require user for these actions * match how map follow works --- app/controllers/maps_controller.rb | 30 ++++++++++++- app/controllers/topics_controller.rb | 50 ++++++++++++++-------- app/models/user.rb | 12 +++++- app/policies/map_policy.rb | 8 ++++ app/policies/topic_policy.rb | 8 ++++ app/views/layouts/_foot.html.erb | 2 +- config/routes.rb | 4 ++ frontend/src/Metamaps/DataModel/Map.js | 3 ++ frontend/src/Metamaps/DataModel/Mapper.js | 16 ++++++- frontend/src/Metamaps/DataModel/Topic.js | 3 ++ frontend/src/Metamaps/Util.js | 3 ++ frontend/src/Metamaps/Views/ExploreMaps.js | 14 ++++++ frontend/src/components/Maps/MapCard.js | 15 ++++--- frontend/src/components/Maps/index.js | 7 +-- 14 files changed, 145 insertions(+), 30 deletions(-) diff --git a/app/controllers/maps_controller.rb b/app/controllers/maps_controller.rb index a91d576d..fb4036d3 100644 --- a/app/controllers/maps_controller.rb +++ b/app/controllers/maps_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MapsController < ApplicationController - before_action :require_user, only: [:create, :update, :destroy, :events] - before_action :set_map, only: [:show, :conversation, :update, :destroy, :contains, :events, :export] + before_action :require_user, only: [:create, :update, :destroy, :events, :follow, :unfollow] + before_action :set_map, only: [:show, :conversation, :update, :destroy, :contains, :events, :export, :follow, :unfollow] after_action :verify_authorized # GET maps/:id @@ -138,6 +138,32 @@ class MapsController < ApplicationController end end + # POST maps/:id/follow + def follow + follow = FollowService.follow(@map, current_user, 'followed') + + respond_to do |format| + format.json do + if follow + head :ok + else + head :bad_request + end + end + end + end + + # POST maps/:id/unfollow + def unfollow + FollowService.unfollow(@map, current_user) + + respond_to do |format| + format.json do + head :ok + end + end + end + private def set_map diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index b09767d0..779e11cf 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -2,7 +2,8 @@ class TopicsController < ApplicationController include TopicsHelper - before_action :require_user, only: [:create, :update, :destroy] + before_action :require_user, only: [:create, :update, :destroy, :follow, :unfollow] + before_action :set_topic, only: [:show, :update, :relative_numbers, :relatives, :network, :destroy, :follow, :unfollow] after_action :verify_authorized, except: :autocomplete_topic respond_to :html, :js, :json @@ -31,9 +32,6 @@ class TopicsController < ApplicationController # GET topics/:id def show - @topic = Topic.find(params[:id]) - authorize @topic - respond_to do |format| format.html do @alltopics = [@topic].concat(policy_scope(Topic.relatives(@topic.id, current_user)).to_a) @@ -49,9 +47,6 @@ class TopicsController < ApplicationController # GET topics/:id/network def network - @topic = Topic.find(params[:id]) - authorize @topic - @alltopics = [@topic].concat(policy_scope(Topic.relatives(@topic.id, current_user)).to_a) @allsynapses = policy_scope(Synapse.for_topic(@topic.id)) @@ -71,9 +66,6 @@ class TopicsController < ApplicationController # GET topics/:id/relative_numbers def relative_numbers - @topic = Topic.find(params[:id]) - authorize @topic - topics_already_has = params[:network] ? params[:network].split(',').map(&:to_i) : [] alltopics = policy_scope(Topic.relatives(@topic.id, current_user)).to_a @@ -94,9 +86,6 @@ class TopicsController < ApplicationController # GET topics/:id/relatives def relatives - @topic = Topic.find(params[:id]) - authorize @topic - topics_already_has = params[:network] ? params[:network].split(',').map(&:to_i) : [] alltopics = policy_scope(Topic.relatives(@topic.id, current_user)).to_a @@ -149,8 +138,6 @@ class TopicsController < ApplicationController # PUT /topics/1 # PUT /topics/1.json def update - @topic = Topic.find(params[:id]) - authorize @topic @topic.updated_by = current_user @topic.assign_attributes(topic_params) @@ -165,8 +152,6 @@ class TopicsController < ApplicationController # DELETE topics/:id def destroy - @topic = Topic.find(params[:id]) - authorize @topic @topic.updated_by = current_user @topic.destroy respond_to do |format| @@ -174,8 +159,39 @@ class TopicsController < ApplicationController end end + # POST topics/:id/follow + def follow + follow = FollowService.follow(@topic, current_user, 'followed') + + respond_to do |format| + format.json do + if follow + head :ok + else + head :bad_request + end + end + end + end + + # POST topics/:id/unfollow + def unfollow + FollowService.unfollow(@topic, current_user) + + respond_to do |format| + format.json do + head :ok + end + end + end + private + def set_topic + @topic = Topic.find(params[:id]) + authorize @topic + end + def topic_params params.require(:topic).permit(:id, :name, :desc, :link, :permission, :metacode_id, :defer_to_map_id) end diff --git a/app/models/user.rb b/app/models/user.rb index e5fadaa9..33ddc8d3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -52,10 +52,20 @@ class User < ApplicationRecord # override default as_json def as_json(_options = {}) - { id: id, + json = { id: id, name: name, image: image.url(:sixtyfour), admin: admin } + if (_options[:follows]) + json['follows'] = { + topics: following.where(followed_type: 'Topic').to_a.map(&:followed_id), + maps: following.where(followed_type: 'Map').to_a.map(&:followed_id) + } + end + if (_options[:email]) + json['email'] = email + end + json end def as_json_for_autocomplete diff --git a/app/policies/map_policy.rb b/app/policies/map_policy.rb index fb9cfdca..3aa9994e 100644 --- a/app/policies/map_policy.rb +++ b/app/policies/map_policy.rb @@ -90,4 +90,12 @@ class MapPolicy < ApplicationPolicy def unstar? user.present? end + + def follow? + show? && user.present? + end + + def unfollow? + user.present? + end end diff --git a/app/policies/topic_policy.rb b/app/policies/topic_policy.rb index bc80f657..cc413d51 100644 --- a/app/policies/topic_policy.rb +++ b/app/policies/topic_policy.rb @@ -55,6 +55,14 @@ class TopicPolicy < ApplicationPolicy show? end + def follow? + show? && user.present? + end + + def unfollow? + user.present? + end + # Helpers def map_policy @map_policy ||= Pundit.policy(user, record.defer_to_map) diff --git a/app/views/layouts/_foot.html.erb b/app/views/layouts/_foot.html.erb index ea4ab671..0912b062 100644 --- a/app/views/layouts/_foot.html.erb +++ b/app/views/layouts/_foot.html.erb @@ -3,7 +3,7 @@ <%= render :partial => 'shared/metacodeBgColors' %> - + - - diff --git a/app/views/shared/_metacodeoptions.html.erb b/app/views/shared/_metacodeoptions.html.erb index 54fb9e48..3cf9604e 100644 --- a/app/views/shared/_metacodeoptions.html.erb +++ b/app/views/shared/_metacodeoptions.html.erb @@ -3,61 +3,7 @@ # this code generates the list of icons that will drop down in the metacode select list on the topic card #%> -
-
    -
  • - Recently Used -
    -
      - <% user_recent_metacodes().each do |m| %> -
    • - <%= m.name %> -
      <%= m.name %>
      -
      -
    • - <% end %> -
    -
  • -
  • - Most Used -
    -
      - <% user_most_used_metacodes().each do |m| %> -
    • - <%= m.name %> -
      <%= m.name %>
      -
      -
    • - <% end %> -
    -
  • - <% MetacodeSet.order("name").all.each do |set| %> -
  • - <%= set.name %> -
    -
      - <% set.metacodes.sort { |a, b| a.name <=> b.name }.each do |m| %> -
    • - <%= m.name %> -
      <%= m.name %>
      -
      -
    • - <% end %> -
    -
  • - <% end %> -
  • - All -
    -
      - <% Metacode.order("name").all.each do |m| %> -
    • - <%= m.name %> -
      <%= m.name %>
      -
      -
    • - <% end %> -
    -
  • -
-
+ diff --git a/frontend/src/Metamaps/DataModel/Topic.js b/frontend/src/Metamaps/DataModel/Topic.js index 8eb09fdf..e8025a7d 100644 --- a/frontend/src/Metamaps/DataModel/Topic.js +++ b/frontend/src/Metamaps/DataModel/Topic.js @@ -4,7 +4,7 @@ try { Backbone.$ = window.$ } catch (err) {} import Active from '../Active' import Filter from '../Filter' -import TopicCard from '../TopicCard' +import TopicCard from '../Views/TopicCard' import Visualize from '../Visualize' import DataModel from './index' diff --git a/frontend/src/Metamaps/JIT.js b/frontend/src/Metamaps/JIT.js index d91a2a93..bd952c21 100644 --- a/frontend/src/Metamaps/JIT.js +++ b/frontend/src/Metamaps/JIT.js @@ -2,9 +2,14 @@ import _ from 'lodash' import outdent from 'outdent' +import clipboard from 'clipboard-js' +import React from 'react' +import ReactDOM from 'react-dom' import $jit from '../patched/JIT' +import MetacodeSelect from '../components/MetacodeSelect' + import Active from './Active' import Control from './Control' import Create from './Create' @@ -18,10 +23,9 @@ import Settings from './Settings' import Synapse from './Synapse' import SynapseCard from './SynapseCard' import Topic from './Topic' -import TopicCard from './TopicCard' +import TopicCard from './Views/TopicCard' import Util from './Util' import Visualize from './Visualize' -import clipboard from 'clipboard-js' let panningInt @@ -1418,9 +1422,7 @@ const JIT = {
` - const metacodeOptions = $('#metacodeOptions').html() - - menustring += '
  • Change metacode' + metacodeOptions + '
  • ' + menustring += '
  • Change metacode
  • ' } if (Active.Topic) { if (!Active.Mapper) { @@ -1475,6 +1477,25 @@ const JIT = { // add the menu to the page $('#wrapper').append(rightclickmenu) + ReactDOM.render( + React.createElement(MetacodeSelect, { + onMetacodeSelect: metacodeId => { + if (Selected.Nodes.length > 1) { + // batch update multiple topics + Control.updateSelectedMetacodes(metacodeId) + } else { + const topic = DataModel.Topics.get(node.id) + topic.save({ + metacode_id: metacodeId + }) + } + $(rightclickmenu).remove() + }, + metacodeSets: TopicCard.metacodeSets + }), + document.getElementById('metacodeOptionsWrapper') + ) + // attach events to clicks on the list items // delete the selected things from the database @@ -1521,13 +1542,6 @@ const JIT = { Control.updateSelectedPermissions($(this).text()) }) - // change the metacode of all the selected nodes that you have edit permission for - $('.rc-metacode li li').click(function() { - $('.rightclickmenu').remove() - // - Control.updateSelectedMetacodes($(this).attr('data-id')) - }) - // fetch relatives let fetchSent = false $('.rc-siblings').hover(function() { diff --git a/frontend/src/Metamaps/Map/index.js b/frontend/src/Metamaps/Map/index.js index 3df5a3e2..0b36a68d 100644 --- a/frontend/src/Metamaps/Map/index.js +++ b/frontend/src/Metamaps/Map/index.js @@ -16,7 +16,7 @@ import Realtime from '../Realtime' import Router from '../Router' import Selected from '../Selected' import SynapseCard from '../SynapseCard' -import TopicCard from '../TopicCard' +import TopicCard from '../Views/TopicCard' import Visualize from '../Visualize' import CheatSheet from './CheatSheet' diff --git a/frontend/src/Metamaps/Topic.js b/frontend/src/Metamaps/Topic.js index 7cdcf3a7..b16da5da 100644 --- a/frontend/src/Metamaps/Topic.js +++ b/frontend/src/Metamaps/Topic.js @@ -14,7 +14,7 @@ import Router from './Router' import Selected from './Selected' import Settings from './Settings' import SynapseCard from './SynapseCard' -import TopicCard from './TopicCard' +import TopicCard from './Views/TopicCard' import Util from './Util' import Visualize from './Visualize' diff --git a/frontend/src/Metamaps/TopicCard.js b/frontend/src/Metamaps/TopicCard.js deleted file mode 100644 index 3fa9a999..00000000 --- a/frontend/src/Metamaps/TopicCard.js +++ /dev/null @@ -1,474 +0,0 @@ -/* global $, CanvasLoader, Countable, Hogan, embedly */ - -import Active from './Active' -import DataModel from './DataModel' -import GlobalUI from './GlobalUI' -import Mapper from './Mapper' -import Router from './Router' -import Util from './Util' -import Visualize from './Visualize' - -const TopicCard = { - openTopicCard: null, // stores the topic that's currently open - authorizedToEdit: false, // stores boolean for edit permission for open topic card - RAILS_ENV: undefined, - init: function(serverData) { - var self = TopicCard - - if (serverData.RAILS_ENV) { - self.RAILS_ENV = serverData.RAILS_ENV - } else { - console.error('RAILS_ENV is not defined! See TopicCard.js init function.') - } - - // initialize best_in_place editing - $('.authenticated div.permission.canEdit .best_in_place').best_in_place() - - TopicCard.generateShowcardHTML = Hogan.compile($('#topicCardTemplate').html()) - - // initialize topic card draggability and resizability - $('.showcard').draggable({ - handle: '.metacodeImage', - stop: function() { - $(this).height('auto') - } - }) - - embedly('on', 'card.rendered', self.embedlyCardRendered) - }, - /** - * Will open the Topic Card for the node that it's passed - * @param {$jit.Graph.Node} node - */ - showCard: function(node, opts) { - var self = TopicCard - if (!opts) opts = {} - var topic = node.getData('topic') - - self.openTopicCard = topic - self.authorizedToEdit = topic.authorizeToEdit(Active.Mapper) - // populate the card that's about to show with the right topics data - self.populateShowCard(topic) - return $('.showcard').fadeIn('fast', function() { - if (opts.complete) { - opts.complete() - } - }) - }, - hideCard: function() { - var self = TopicCard - - $('.showcard').fadeOut('fast') - self.openTopicCard = null - self.authorizedToEdit = false - }, - embedlyCardRendered: function(iframe) { - $('#embedlyLinkLoader').hide() - - // means that the embedly call returned 404 not found - if ($('#embedlyLink')[0]) { - $('#embedlyLink').css('display', 'block').fadeIn('fast') - $('.embeds').addClass('nonEmbedlyLink') - } - - $('.CardOnGraph').addClass('hasAttachment') - }, - showLinkRemover: function() { - if (TopicCard.authorizedToEdit && $('#linkremove').length === 0) { - $('.embeds').append('
    ') - $('#linkremove').click(TopicCard.removeLink) - } - }, - removeLink: function() { - var self = TopicCard - self.openTopicCard.save({ - link: null - }) - $('.embeds').empty().removeClass('nonEmbedlyLink') - $('#addLinkInput input').val('') - $('.attachments').removeClass('hidden') - $('.CardOnGraph').removeClass('hasAttachment') - }, - showLinkLoader: function() { - var loader = new CanvasLoader('embedlyLinkLoader') - loader.setColor('#4fb5c0') // default is '#000000' - loader.setDiameter(28) // default is 40 - loader.setDensity(41) // default is 40 - loader.setRange(0.9) // default is 1.3 - loader.show() // Hidden by default - }, - showLink: function(topic) { - var e = embedly('card', document.getElementById('embedlyLink')) - if (!e && TopicCard.RAILS_ENV !== 'development') { - TopicCard.handleInvalidLink() - } else if (!e) { - $('#embedlyLink').attr('target', '_blank').html(topic.get('link')).show() - $('#embedlyLinkLoader').hide() - } - }, - bindShowCardListeners: function(topic) { - var self = TopicCard - var showCard = document.getElementById('showcard') - - var authorized = self.authorizedToEdit - - // get mapper image - var setMapperImage = function(mapper) { - $('.contributorIcon').attr('src', mapper.get('image')) - } - Mapper.get(topic.get('user_id'), setMapperImage) - - // starting embed.ly - var resetFunc = function() { - $('#addLinkInput input').val('') - $('#addLinkInput input').focus() - } - var inputEmbedFunc = function(event) { - var element = this - setTimeout(function() { - var text = $(element).val() - if (event.type === 'paste' || (event.type === 'keyup' && event.which === 13)) { - // TODO evaluate converting this to '//' no matter what (infer protocol) - if (text.slice(0, 7) !== 'http://' && - text.slice(0, 8) !== 'https://' && - text.slice(0, 2) !== '//') { - text = '//' + text - } - topic.save({ - link: text - }) - var embedlyEl = $('', { - id: 'embedlyLink', - 'data-card-description': '0', - href: text - }).html(text) - $('.attachments').addClass('hidden') - $('.embeds').append(embedlyEl) - $('.embeds').append('
    ') - - self.showLinkLoader() - self.showLink(topic) - } - }, 100) - } - $('#addLinkReset').click(resetFunc) - $('#addLinkInput input').bind('paste keyup', inputEmbedFunc) - - // initialize the link card, if there is a link - if (topic.get('link') && topic.get('link') !== '') { - self.showLinkLoader() - self.showLink(topic) - self.showLinkRemover() - } - - var selectingMetacode = false - // attach the listener that shows the metacode title when you hover over the image - $('.showcard .metacodeImage').mouseenter(function() { - $('.showcard .icon').css('z-index', '4') - $('.showcard .metacodeTitle').show() - }) - $('.showcard .linkItem.icon').mouseleave(function() { - if (!selectingMetacode) { - $('.showcard .metacodeTitle').hide() - $('.showcard .icon').css('z-index', '1') - } - }) - - var metacodeLiClick = function() { - selectingMetacode = false - var metacodeId = parseInt($(this).attr('data-id')) - var metacode = DataModel.Metacodes.get(metacodeId) - $('.CardOnGraph').find('.metacodeTitle').html(metacode.get('name')) - .append('
    ') - .attr('class', 'metacodeTitle mbg' + metacode.id) - $('.CardOnGraph').find('.metacodeImage').css('background-image', 'url(' + metacode.get('icon') + ')') - topic.save({ - metacode_id: metacode.id - }) - Visualize.mGraph.plot() - $('.metacodeSelect').hide().removeClass('onRightEdge onBottomEdge') - $('.metacodeTitle').hide() - $('.showcard .icon').css('z-index', '1') - } - - var openMetacodeSelect = function(event) { - var TOPICCARD_WIDTH = 300 - var METACODESELECT_WIDTH = 404 - var MAX_METACODELIST_HEIGHT = 270 - - if (!selectingMetacode) { - selectingMetacode = true - - // this is to make sure the metacode - // select is accessible onscreen, when opened - // while topic card is close to the right - // edge of the screen - var windowWidth = $(window).width() - var showcardLeft = parseInt($('.showcard').css('left')) - var distanceFromEdge = windowWidth - (showcardLeft + TOPICCARD_WIDTH) - if (distanceFromEdge < METACODESELECT_WIDTH) { - $('.metacodeSelect').addClass('onRightEdge') - } - - // this is to make sure the metacode - // select is accessible onscreen, when opened - // while topic card is close to the bottom - // edge of the screen - var windowHeight = $(window).height() - var showcardTop = parseInt($('.showcard').css('top')) - var topicTitleHeight = $('.showcard .title').height() + parseInt($('.showcard .title').css('padding-top')) + parseInt($('.showcard .title').css('padding-bottom')) - var distanceFromBottom = windowHeight - (showcardTop + topicTitleHeight) - if (distanceFromBottom < MAX_METACODELIST_HEIGHT) { - $('.metacodeSelect').addClass('onBottomEdge') - } - - $('.metacodeSelect').show() - event.stopPropagation() - } - } - - var hideMetacodeSelect = function() { - selectingMetacode = false - $('.metacodeSelect').hide().removeClass('onRightEdge onBottomEdge') - $('.metacodeTitle').hide() - $('.showcard .icon').css('z-index', '1') - } - - if (authorized) { - $('.showcard .metacodeTitle').click(openMetacodeSelect) - $('.showcard').click(hideMetacodeSelect) - $('.metacodeSelect > ul > li').click(function(event) { - event.stopPropagation() - }) - $('.metacodeSelect li li').click(metacodeLiClick) - - var bipName = $(showCard).find('.best_in_place_name') - bipName.bind('best_in_place:activate', function() { - var $el = bipName.find('textarea') - var el = $el[0] - - $el.attr('maxlength', '140') - - $('.showcard .title').append('
    ') - - var callback = function(data) { - $('.nameCounter.forTopic').html(data.all + '/140') - } - Countable.live(el, callback) - }) - bipName.bind('best_in_place:deactivate', function() { - $('.nameCounter.forTopic').remove() - }) - bipName.keypress(function(e) { - const ENTER = 13 - if (e.which === ENTER) { // enter - $(this).data('bestInPlaceEditor').update() - } - }) - - // bind best_in_place ajax callbacks - bipName.bind('ajax:success', function() { - var name = Util.decodeEntities($(this).html()) - topic.set('name', name) - topic.trigger('saved') - }) - - // this is for all subsequent renders after in-place editing the desc field - const bipDesc = $(showCard).find('.best_in_place_desc') - bipDesc.bind('ajax:success', function() { - var desc = $(this).html() === $(this).data('bip-nil') - ? '' - : $(this).text() - topic.set('desc', desc) - $(this).data('bip-value', desc) - this.innerHTML = Util.mdToHTML(desc) - topic.trigger('saved') - }) - bipDesc.keypress(function(e) { - // allow typing Enter with Shift+Enter - const ENTER = 13 - if (e.shiftKey === false && e.which === ENTER) { - $(this).data('bestInPlaceEditor').update() - } - }) - } - - var permissionLiClick = function(event) { - selectingPermission = false - var permission = $(this).attr('class') - topic.save({ - permission: permission, - defer_to_map_id: null - }) - $('.showcard .mapPerm').removeClass('co pu pr minimize').addClass(permission.substring(0, 2)) - $('.showcard .permissionSelect').remove() - event.stopPropagation() - } - - var openPermissionSelect = function(event) { - if (!selectingPermission) { - selectingPermission = true - $(this).addClass('minimize') // this line flips the drop down arrow to a pull up arrow - if ($(this).hasClass('co')) { - $(this).append('
    ') - } else if ($(this).hasClass('pu')) { - $(this).append('
    ') - } else if ($(this).hasClass('pr')) { - $(this).append('
    ') - } - $('.showcard .permissionSelect li').click(permissionLiClick) - event.stopPropagation() - } - } - - var hidePermissionSelect = function() { - selectingPermission = false - $('.showcard .yourTopic .mapPerm').removeClass('minimize') // this line flips the pull up arrow to a drop down arrow - $('.showcard .permissionSelect').remove() - } - // ability to change permission - var selectingPermission = false - if (topic.authorizePermissionChange(Active.Mapper)) { - $('.showcard .yourTopic .mapPerm').click(openPermissionSelect) - $('.showcard').click(hidePermissionSelect) - } - - $('.links .mapCount').unbind().click(function(event) { - $('.mapCount .tip').toggle() - $('.showcard .hoverTip').toggleClass('hide') - event.stopPropagation() - }) - $('.mapCount .tip').unbind().click(function(event) { - event.stopPropagation() - }) - $('.showcard').unbind('.hideTip').bind('click.hideTip', function() { - $('.mapCount .tip').hide() - $('.showcard .hoverTip').removeClass('hide') - }) - - $('.mapCount .tip li a').click(Router.intercept) - - var originalText = $('.showMore').html() - $('.mapCount .tip .showMore').unbind().toggle( - function(event) { - $('.extraText').toggleClass('hideExtra') - $('.showMore').html('Show less...') - }, - function(event) { - $('.extraText').toggleClass('hideExtra') - $('.showMore').html(originalText) - }) - - $('.mapCount .tip showMore').unbind().click(function(event) { - event.stopPropagation() - }) - }, - handleInvalidLink: function() { - var self = TopicCard - - self.removeLink() - GlobalUI.notifyUser('Invalid link') - }, - populateShowCard: function(topic) { - var self = TopicCard - - var showCard = document.getElementById('showcard') - - $(showCard).find('.permission').remove() - - var topicForTemplate = self.buildObject(topic) - var html = self.generateShowcardHTML.render(topicForTemplate) - - if (topic.authorizeToEdit(Active.Mapper)) { - let perm = document.createElement('div') - - var string = 'permission canEdit' - if (topic.authorizePermissionChange(Active.Mapper)) string += ' yourTopic' - perm.className = string - perm.innerHTML = html - showCard.appendChild(perm) - } else { - let perm = document.createElement('div') - perm.className = 'permission cannotEdit' - perm.innerHTML = html - showCard.appendChild(perm) - } - - TopicCard.bindShowCardListeners(topic) - }, - generateShowcardHTML: null, // will be initialized into a Hogan template within init function - // generateShowcardHTML - buildObject: function(topic) { - var nodeValues = {} - - var authorized = topic.authorizeToEdit(Active.Mapper) - - if (!authorized) { - } else { - } - - nodeValues.attachmentsHidden = '' - if (topic.get('link') && topic.get('link') !== '') { - nodeValues.embeds = '
    ' - nodeValues.embeds += topic.get('link') - nodeValues.embeds += '
    ' - nodeValues.attachmentsHidden = 'hidden' - nodeValues.hasAttachment = 'hasAttachment' - } else { - nodeValues.embeds = '' - nodeValues.hasAttachment = '' - } - - if (authorized) { - nodeValues.attachments = '' - } else { - nodeValues.attachmentsHidden = 'hidden' - nodeValues.attachments = '' - } - - var inmapsAr = topic.get('inmaps') || [] - var inmapsLinks = topic.get('inmapsLinks') || [] - nodeValues.inmaps = '' - if (inmapsAr.length < 6) { - for (let i = 0; i < inmapsAr.length; i++) { - const url = '/maps/' + inmapsLinks[i] - nodeValues.inmaps += '
  • ' + inmapsAr[i] + '
  • ' - } - } else { - for (let i = 0; i < 5; i++) { - const url = '/maps/' + inmapsLinks[i] - nodeValues.inmaps += '
  • ' + inmapsAr[i] + '
  • ' - } - const extra = inmapsAr.length - 5 - nodeValues.inmaps += '
  • See ' + extra + ' more...
  • ' - for (let i = 5; i < inmapsAr.length; i++) { - const url = '/maps/' + inmapsLinks[i] - nodeValues.inmaps += '
  • ' + inmapsAr[i] + '
  • ' - } - } - nodeValues.permission = topic.get('permission') - nodeValues.mk_permission = topic.get('permission').substring(0, 2) - nodeValues.map_count = topic.get('map_count').toString() - nodeValues.synapse_count = topic.get('synapse_count').toString() - nodeValues.id = topic.isNew() ? topic.cid : topic.id - nodeValues.metacode = topic.getMetacode().get('name') - nodeValues.metacode_class = 'mbg' + topic.get('metacode_id') - nodeValues.imgsrc = topic.getMetacode().get('icon') - nodeValues.name = topic.get('name') - nodeValues.userid = topic.get('user_id') - nodeValues.username = topic.get('user_name') - nodeValues.date = topic.getDate() - // the code for this is stored in /views/main/_metacodeOptions.html.erb - nodeValues.metacode_select = $('#metacodeOptions').html() - nodeValues.desc_nil = 'Click to add description...' - nodeValues.desc_markdown = (topic.get('desc') === '' && authorized) - ? nodeValues.desc_nil - : topic.get('desc') - nodeValues.desc_html = Util.mdToHTML(nodeValues.desc_markdown) - return nodeValues - } -} - -export default TopicCard diff --git a/frontend/src/Metamaps/Util.js b/frontend/src/Metamaps/Util.js index c0c3d7ce..ed9783c3 100644 --- a/frontend/src/Metamaps/Util.js +++ b/frontend/src/Metamaps/Util.js @@ -1,6 +1,6 @@ /* global $ */ -import { Parser, HtmlRenderer } from 'commonmark' +import { Parser, HtmlRenderer, Node } from 'commonmark' import { emojiIndex } from 'emoji-mart' import { escapeRegExp } from 'lodash' @@ -135,9 +135,26 @@ const Util = { }, mdToHTML: text => { const safeText = text || '' + const parsed = new Parser().parse(safeText) + + // remove images to avoid http content in https context + const walker = parsed.walker() + for (let event = walker.next(); event = walker.next(); event) { + const node = event.node + if (node.type === 'image') { + const imageAlt = node.firstChild.literal + const imageSrc = node.destination + const textNode = new Node('text', node.sourcepos) + textNode.literal = `![${imageAlt}](${imageSrc})` + + node.insertBefore(textNode) + node.unlink() // remove the image, replacing it with markdown + walker.resumeAt(textNode, false) + } + } + // use safe: true to filter xss - return new HtmlRenderer({ safe: true }) - .render(new Parser().parse(safeText)) + return new HtmlRenderer({ safe: true }).render(parsed) }, logCanvasAttributes: function(canvas) { const fakeMgraph = { canvas } diff --git a/frontend/src/Metamaps/Views/TopicCard.js b/frontend/src/Metamaps/Views/TopicCard.js new file mode 100644 index 00000000..51036685 --- /dev/null +++ b/frontend/src/Metamaps/Views/TopicCard.js @@ -0,0 +1,59 @@ +/* global $ */ + +import React from 'react' +import ReactDOM from 'react-dom' + +import Active from '../Active' +import Visualize from '../Visualize' + +import ReactTopicCard from '../../components/TopicCard' + +const TopicCard = { + openTopicCard: null, // stores the topic that's currently open + metacodeSets: [], + init: function(serverData) { + const self = TopicCard + self.metacodeSets = serverData.metacodeSets + }, + populateShowCard: function(topic) { + const self = TopicCard + ReactDOM.render( + React.createElement(ReactTopicCard, { + topic: topic, + ActiveMapper: Active.Mapper, + updateTopic: obj => { + topic.save(obj, { success: topic => self.populateShowCard(topic) }) + }, + metacodeSets: self.metacodeSets, + redrawCanvas: () => { + Visualize.mGraph.plot() + } + }), + document.getElementById('showcard') + ) + + // initialize draggability + $('.showcard').draggable({ + handle: '.metacodeImage', + stop: function() { + $(this).height('auto') + } + }) + }, + showCard: function(node, opts) { + var self = TopicCard + if (!opts) opts = {} + var topic = node.getData('topic') + self.openTopicCard = topic + // populate the card that's about to show with the right topics data + self.populateShowCard(topic) + return $('.showcard').fadeIn('fast', () => opts.complete && opts.complete()) + }, + hideCard: function() { + var self = TopicCard + $('.showcard').fadeOut('fast') + self.openTopicCard = null + } +} + +export default TopicCard diff --git a/frontend/src/Metamaps/Views/index.js b/frontend/src/Metamaps/Views/index.js index c496b3b0..85a710c3 100644 --- a/frontend/src/Metamaps/Views/index.js +++ b/frontend/src/Metamaps/Views/index.js @@ -4,18 +4,21 @@ import ExploreMaps from './ExploreMaps' import ChatView from './ChatView' import VideoView from './VideoView' import Room from './Room' +import TopicCard from './TopicCard' import { JUNTO_UPDATED } from '../Realtime/events' const Views = { init: (serverData) => { $(document).on(JUNTO_UPDATED, () => ExploreMaps.render()) ChatView.init([serverData['sounds/MM_sounds.mp3'], serverData['sounds/MM_sounds.ogg']]) + TopicCard.init(serverData) }, ExploreMaps, ChatView, VideoView, - Room + Room, + TopicCard } -export { ExploreMaps, ChatView, VideoView, Room } +export { ExploreMaps, ChatView, VideoView, Room, TopicCard } export default Views diff --git a/frontend/src/Metamaps/Visualize.js b/frontend/src/Metamaps/Visualize.js index 2ccb08ed..43f6071b 100644 --- a/frontend/src/Metamaps/Visualize.js +++ b/frontend/src/Metamaps/Visualize.js @@ -9,7 +9,7 @@ import DataModel from './DataModel' import JIT from './JIT' import Loading from './Loading' import Router from './Router' -import TopicCard from './TopicCard' +import TopicCard from './Views/TopicCard' const Visualize = { mGraph: null, // a reference to the graph object. diff --git a/frontend/src/Metamaps/index.js b/frontend/src/Metamaps/index.js index 61f5e18a..eb9969b0 100644 --- a/frontend/src/Metamaps/index.js +++ b/frontend/src/Metamaps/index.js @@ -29,7 +29,6 @@ import Settings from './Settings' import Synapse from './Synapse' import SynapseCard from './SynapseCard' import Topic from './Topic' -import TopicCard from './TopicCard' import Util from './Util' import Views from './Views' import Visualize from './Visualize' @@ -71,7 +70,6 @@ Metamaps.Settings = Settings Metamaps.Synapse = Synapse Metamaps.SynapseCard = SynapseCard Metamaps.Topic = Topic -Metamaps.TopicCard = TopicCard Metamaps.Util = Util Metamaps.Views = Views Metamaps.Visualize = Visualize diff --git a/frontend/src/components/MetacodeSelect.js b/frontend/src/components/MetacodeSelect.js new file mode 100644 index 00000000..68da09e8 --- /dev/null +++ b/frontend/src/components/MetacodeSelect.js @@ -0,0 +1,53 @@ +/* global $ */ + +/* + * Metacode selector component + * + * This component takes in a callback (onMetacodeSelect; takes one metacode id) + * and a list of metacode sets and renders them. If you click a metacode, it + * passes that metacode's id to the callback. + */ + +import React, { PropTypes, Component } from 'react' + +class MetacodeSelect extends Component { + render = () => { + return ( +
    +
      + {this.props.metacodeSets.map(set => ( +
    • + {set.name} +
      +
        + {set.metacodes.map(m => ( +
      • this.props.onMetacodeSelect(m.id)} + > + {m.name} +
        {m.name}
        +
        +
      • + ))} +
      +
    • + ))} +
    +
    + ) + } +} + +MetacodeSelect.propTypes = { + onMetacodeClick: PropTypes.func, + metacodeSets: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + metacodes: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number, + icon_path: PropTypes.string, // url + name: PropTypes.string + })) + })) +} + +export default MetacodeSelect diff --git a/frontend/src/components/TopicCard/Attachments.js b/frontend/src/components/TopicCard/Attachments.js new file mode 100644 index 00000000..3e04dfbc --- /dev/null +++ b/frontend/src/components/TopicCard/Attachments.js @@ -0,0 +1,24 @@ +import React, { PropTypes, Component } from 'react' + +import EmbedlyLink from './EmbedlyLink' + +class Attachments extends Component { + render = () => { + const { topic, authorizedToEdit, updateTopic } = this.props + const link = topic.get('link') + + return ( +
    + +
    + ) + } +} + +Attachments.propTypes = { + topic: PropTypes.object, // Backbone object + authorizedToEdit: PropTypes.bool, + updateTopic: PropTypes.func +} + +export default Attachments diff --git a/frontend/src/components/TopicCard/Desc.js b/frontend/src/components/TopicCard/Desc.js new file mode 100644 index 00000000..c94f30fb --- /dev/null +++ b/frontend/src/components/TopicCard/Desc.js @@ -0,0 +1,77 @@ +import React, { PropTypes, Component } from 'react' +import { RIETextArea } from 'riek' +import Util from '../../Metamaps/Util' + +class MdTextArea extends RIETextArea { + keyDown = (event) => { + // we'll handle Enter on our own, thanks + const ESC = 27 + if (event.keyCode === ESC) { + this.cancelEditing() + } + } + + renderNormalComponent = () => { + // defaultProps MUST use dangerouslySetInnerHTML + return + } +} + +class Desc extends Component { + render = () => { + const descHTML = (!this.props.desc && this.props.authorizedToEdit) + ? '

    Click to add description...

    ' + : Util.mdToHTML(this.props.desc) + + if (this.props.authorizedToEdit) { + return ( +
    +
    + { + const ENTER = 13 + if (!e.shiftKey && e.which === ENTER) { + e.preventDefault() + this.props.onChange({ desc: e.target.value }) + } + } + }} + defaultProps={{ + dangerouslySetInnerHTML: { __html: descHTML } + }} + /> +
    +
    +
    + ) + } else { + return ( +
    +
    + + {this.props.desc} + +
    +
    + ) + } + } +} + +Desc.propTypes = { + desc: PropTypes.string, // markdown + authorizedToEdit: PropTypes.bool, + onChange: PropTypes.func +} + +export default Desc diff --git a/frontend/src/components/TopicCard/EmbedlyLink/Card.js b/frontend/src/components/TopicCard/EmbedlyLink/Card.js new file mode 100644 index 00000000..6d251310 --- /dev/null +++ b/frontend/src/components/TopicCard/EmbedlyLink/Card.js @@ -0,0 +1,65 @@ +/* global $, embedly */ +import React, { PropTypes, Component } from 'react' + +class EmbedlyCard extends Component { + constructor(props) { + super(props) + + this.state = { + embedlyLinkStarted: false, + embedlyLinkLoaded: false, + embedlyLinkError: false + } + } + + componentDidMount = () => { + embedly('on', 'card.rendered', this.embedlyCardRendered) + if (this.props.link) this.loadLink() + } + + componentWillUnmount = () => { + embedly('off') + } + + componentDidUpdate = () => { + const { embedlyLinkStarted } = this.state + !embedlyLinkStarted && this.props.link && this.loadLink() + } + + embedlyCardRendered = (iframe, test) => { + this.setState({embedlyLinkLoaded: true, embedlyLinkError: false}) + } + + loadLink = () => { + this.setState({ embedlyLinkStarted: true }) + var e = embedly('card', document.getElementById('embedlyLink')) + if (e && e.type === 'error') this.setState({embedlyLinkError: true}) + } + + render = () => { + const { link } = this.props + const { embedlyLinkLoaded, embedlyLinkStarted, embedlyLinkError } = this.state + + const notReady = embedlyLinkStarted && !embedlyLinkLoaded && !embedlyLinkError + + return ( +
    + + {link} + + {notReady &&
    loading...
    } +
    + ) + } +} + +EmbedlyCard.propTypes = { + link: PropTypes.string +} + +export default EmbedlyCard diff --git a/frontend/src/components/TopicCard/EmbedlyLink/index.js b/frontend/src/components/TopicCard/EmbedlyLink/index.js new file mode 100644 index 00000000..1775ab03 --- /dev/null +++ b/frontend/src/components/TopicCard/EmbedlyLink/index.js @@ -0,0 +1,76 @@ +/* global embedly */ +import React, { PropTypes, Component } from 'react' + +import Card from './Card' + +class EmbedlyLink extends Component { + constructor(props) { + super(props) + + this.state = { + linkEdit: '' + } + } + + removeLink = () => { + this.props.updateTopic({ link: null }) + } + + resetLink = () => { + this.setState({ linkEdit: '' }) + } + + onLinkChangeHandler = e => { + this.setState({ linkEdit: e.target.value }) + } + + onLinkKeyUpHandler = e => { + const ENTER_KEY = 13 + if (e.which === ENTER_KEY) { + const { linkEdit } = this.state + this.setState({ linkEdit: '' }) + this.props.updateTopic({ link: linkEdit }) + } + } + + render = () => { + const { link, authorizedToEdit } = this.props + const { linkEdit } = this.state + const hasAttachment = !!link + + if (!hasAttachment && !authorizedToEdit) return null + + return ( +
    +
    +
    +
    + (this.linkInput = input)} + placeholder="Enter or paste a link" + value={linkEdit} + onChange={this.onLinkChangeHandler} + onKeyUp={this.onLinkKeyUpHandler}> + {linkEdit &&
    } +
    +
    + {link && } + {authorizedToEdit && ( +
    + )} +
    + ) + } +} + +EmbedlyLink.propTypes = { + link: PropTypes.string, + authorizedToEdit: PropTypes.bool, + updateTopic: PropTypes.func +} + +export default EmbedlyLink diff --git a/frontend/src/components/TopicCard/Links.js b/frontend/src/components/TopicCard/Links.js new file mode 100644 index 00000000..ad1758d3 --- /dev/null +++ b/frontend/src/components/TopicCard/Links.js @@ -0,0 +1,161 @@ +/* global $ */ + +import React, { PropTypes, Component } from 'react' + +import MetacodeSelect from '../MetacodeSelect' +import Permission from './Permission' + +class Links extends Component { + constructor(props) { + super(props) + + this.state = { + showMetacodeTitle: false, + showMetacodeSelect: false, + showInMaps: false, + showMoreMaps: false, + hoveringMapCount: false, + hoveringSynapseCount: false + } + } + + handleMetacodeSelect = metacodeId => { + this.setState({ showMetacodeSelect: false }) + this.props.updateTopic({ + metacode_id: metacodeId + }) + this.props.redrawCanvas() + } + + toggleShowMoreMaps = e => { + e.stopPropagation() + e.preventDefault() + this.setState({ showMoreMaps: !this.state.showMoreMaps }) + } + + updateState = (key, value) => () => { + this.setState({ [key]: value }) + } + + inMaps = (topic) => { + const inmapsArray = topic.get('inmaps') || [] + const inmapsLinks = topic.get('inmapsLinks') || [] + + let firstFiveLinks = [] + let extraLinks = [] + for (let i = 0; i < inmapsArray.length; i ++) { + if (i < 5) { + firstFiveLinks.push({ mapName: inmapsArray[i], mapId: inmapsLinks[i] }) + } else { + extraLinks.push({ mapName: inmapsArray[i], mapId: inmapsLinks[i] }) + } + } + + let output = [] + + firstFiveLinks.forEach(obj => { + output.push(
  • {obj.mapName}
  • ) + }) + + if (extraLinks.length > 0) { + if (this.state.showMoreMaps) { + extraLinks.forEach(obj => { + output.push(
  • {obj.mapName}
  • ) + }) + } + const text = this.state.showMoreMaps ? 'See less...' : `See ${extraLinks.length} more...` + output.push(
  • {text}
  • ) + } + + return output + } + + handleMetacodeBarClick = () => { + if (this.state.showMetacodeTitle) { + this.setState({ showMetacodeSelect: !this.state.showMetacodeSelect }) + } + } + + render = () => { + const { topic, ActiveMapper } = this.props + const authorizedToEdit = topic.authorizeToEdit(ActiveMapper) + const authorizedPermissionChange = topic.authorizePermissionChange(ActiveMapper) + const metacode = topic.getMetacode() + + return ( +
    +
    this.setState({ showMetacodeTitle: false, showMetacodeSelect: false })} + onClick={this.handleMetacodeBarClick} + > +
    + {metacode.get('name')} +
    +
    +
    this.setState({ showMetacodeTitle: true })} + /> +
    + +
    +
    +
    + +
    {topic.get('user_name')}
    +
    +
    +
    + {topic.get('map_count').toString()} + {!this.state.showInMaps && this.state.hoveringMapCount && ( +
    Click to see which maps topic appears on
    + )} + {this.state.showInMaps &&
      {this.inMaps(topic)}
    } +
    + +
    + {topic.get('synapse_count').toString()} + {this.state.hoveringSynapseCount &&
    Click to see this topics synapses
    } +
    + +
    +
    + ) + } +} + +Links.propTypes = { + topic: PropTypes.object, // backbone object + ActiveMapper: PropTypes.object, + updateTopic: PropTypes.func, + metacodeSets: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + metacodes: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number, + icon_path: PropTypes.string, // url + name: PropTypes.string + })) + })), + redrawCanvas: PropTypes.func +} + +export default Links diff --git a/frontend/src/components/TopicCard/Permission.js b/frontend/src/components/TopicCard/Permission.js new file mode 100644 index 00000000..bceb2d4c --- /dev/null +++ b/frontend/src/components/TopicCard/Permission.js @@ -0,0 +1,69 @@ +import React, { PropTypes, Component } from 'react' + +import onClickOutsideAddon from 'react-onclickoutside' + +class Permission extends Component { + constructor(props) { + super(props) + this.state = { + selectingPermission: false + } + } + + togglePermissionSelect = () => { + this.setState({selectingPermission: !this.state.selectingPermission}) + } + + openPermissionSelect = () => { + this.setState({selectingPermission: true}) + } + + closePermissionSelect = () => { + this.setState({selectingPermission: false}) + } + + handleClickOutside = instance => { + this.closePermissionSelect() + } + + liClick = value => event => { + this.closePermissionSelect() + this.props.updateTopic({ + permission: value, + defer_to_map_id: null + }) + // prevents it from also firing the event listener on the parent + event.preventDefault() + } + + render = () => { + const { permission, authorizedToEdit } = this.props + const { selectingPermission } = this.state + + let classes = `linkItem mapPerm ${permission.substring(0, 2)}` + if (selectingPermission) classes += ' minimize' + + return ( +
    +
      + {permission !== 'commons' &&
    • } + {permission !== 'public' &&
    • } + {permission !== 'private' &&
    • } +
    +
    + ) + } +} + +Permission.propTypes = { + permission: PropTypes.string, // 'co', 'pu', or 'pr' + authorizedToEdit: PropTypes.bool, + updateTopic: PropTypes.func +} + +export default onClickOutsideAddon(Permission) diff --git a/frontend/src/components/TopicCard/Title.js b/frontend/src/components/TopicCard/Title.js new file mode 100644 index 00000000..1eca527b --- /dev/null +++ b/frontend/src/components/TopicCard/Title.js @@ -0,0 +1,62 @@ +import React, { Component, PropTypes } from 'react' +import { RIETextArea } from 'riek' + +const maxTitleLength = 140 + +class Title extends Component { + nameCounterText() { + // for some reason, there's an error if this isn't inside a function + return `${this.props.name.length}/${maxTitleLength.toString()}` + } + + render() { + if (this.props.authorizedToEdit) { + return ( + + { this.textarea = textarea }} + propName="name" + change={this.props.onChange} + className="titleWrapper" + id="titleActivator" + classEditing="riek-editing" + editProps={{ + maxLength: maxTitleLength, + onKeyPress: e => { + const ENTER = 13 + if (e.which === ENTER) { + e.preventDefault() + this.props.onChange({ name: e.target.value }) + } + }, + onChange: e => { + if (!this.nameCounter) return + this.nameCounter.innerHTML = `${e.target.value.length}/140` + } + }} + /> + { this.nameCounter = span }}> + {this.nameCounterText()} + + + ) + } else { + return ( + + + {this.props.name} + + + ) + } + } +} + + +Title.propTypes = { + name: PropTypes.string, + onChange: PropTypes.func, + authorizedToEdit: PropTypes.bool +} + +export default Title diff --git a/frontend/src/components/TopicCard/index.js b/frontend/src/components/TopicCard/index.js new file mode 100644 index 00000000..3ebe700a --- /dev/null +++ b/frontend/src/components/TopicCard/index.js @@ -0,0 +1,65 @@ +import React, { PropTypes, Component } from 'react' + +import Title from './Title' +import Links from './Links' +import Desc from './Desc' +import Attachments from './Attachments' + +class ReactTopicCard extends Component { + render = () => { + const { topic, ActiveMapper } = this.props + const authorizedToEdit = topic.authorizeToEdit(ActiveMapper) + const hasAttachment = topic.get('link') && topic.get('link') !== '' + + let classname = 'permission' + if (authorizedToEdit) { + classname += ' canEdit' + } else { + classname += ' cannotEdit' + } + if (topic.authorizePermissionChange(ActiveMapper)) classname += ' yourTopic' + + return ( +
    +
    + + <Links topic={topic} + ActiveMapper={this.props.ActiveMapper} + updateTopic={this.props.updateTopic} + metacodeSets={this.props.metacodeSets} + redrawCanvas={this.props.redrawCanvas} + /> + <Desc desc={topic.get('desc')} + authorizedToEdit={authorizedToEdit} + onChange={this.props.updateTopic} + /> + <Attachments topic={topic} + authorizedToEdit={authorizedToEdit} + updateTopic={this.props.updateTopic} + /> + <div className="clearfloat"></div> + </div> + </div> + ) + } +} + +ReactTopicCard.propTypes = { + topic: PropTypes.object, + ActiveMapper: PropTypes.object, + updateTopic: PropTypes.func, + metacodeSets: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + metacodes: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number, + icon_path: PropTypes.string, // url + name: PropTypes.string + })) + })), + redrawCanvas: PropTypes.func +} + +export default ReactTopicCard diff --git a/frontend/test/Metamaps.Util.spec.js b/frontend/test/Metamaps.Util.spec.js index e0366bd9..80108ee2 100644 --- a/frontend/test/Metamaps.Util.spec.js +++ b/frontend/test/Metamaps.Util.spec.js @@ -113,9 +113,15 @@ describe('Metamaps.Util.js', function() { expect(Util.mdToHTML(md).trim()).to.equal(html) }) - it('links and images', function() { - const md = '[Link](https://metamaps.cc) ![Image](https://example.org/image.png)' - const html = '<p><a href="https://metamaps.cc">Link</a> <img src="https://example.org/image.png" alt="Image" /></p>' + it('links', function() { + const md = '[Link](https://metamaps.cc)' + const html = '<p><a href="https://metamaps.cc">Link</a></p>' + expect(Util.mdToHTML(md).trim()).to.equal(html) + }) + + it('images are not rendered', function() { + const md = '![Image](https://example.org/image.png)' + const html = '<p>![Image](https://example.org/image.png)</p>' expect(Util.mdToHTML(md).trim()).to.equal(html) }) }) diff --git a/package.json b/package.json index 104bcc5e..6db62390 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "react": "15.4.2", "react-dom": "15.4.2", "react-dropzone": "3.9.1", + "react-onclickoutside": "^5.9.0", "redux": "3.6.0", + "riek": "^1.0.7", "simplewebrtc": "2.2.2", "socket.io": "1.3.7", "webpack": "1.14.0" From ba943b20f1e1259b874e6620e5e921c28c74cb13 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Mon, 27 Feb 2017 17:06:56 -0500 Subject: [PATCH 13/32] hellz yeah (#1074) --- frontend/src/Metamaps/Util.js | 28 +++++ frontend/src/patched/JIT.js | 210 +++++++++++++++++----------------- 2 files changed, 130 insertions(+), 108 deletions(-) diff --git a/frontend/src/Metamaps/Util.js b/frontend/src/Metamaps/Util.js index ed9783c3..e371e4e0 100644 --- a/frontend/src/Metamaps/Util.js +++ b/frontend/src/Metamaps/Util.js @@ -201,6 +201,34 @@ const Util = { }, isTester: function(currentUser) { return ['connorturland@gmail.com', 'devin@callysto.com', 'chessscholar@gmail.com', 'solaureum@gmail.com', 'ishanshapiro@gmail.com'].indexOf(currentUser.get('email')) > -1 + }, + zoomOnPoint: function(graph, ans, zoomPoint) { + var s = graph.canvas.getSize(), + p = graph.canvas.getPos(), + ox = graph.canvas.translateOffsetX, + oy = graph.canvas.translateOffsetY, + sx = graph.canvas.scaleOffsetX, + sy = graph.canvas.scaleOffsetY; + + var pointerCoordX = (zoomPoint.x - p.x - s.width / 2 - ox) * (1 / sx), + pointerCoordY = (zoomPoint.y - p.y - s.height / 2 - oy) * (1 / sy); + + //This translates the canvas to be centred over the zoomPoint, then the canvas is zoomed as intended. + graph.canvas.translate(-pointerCoordX,-pointerCoordY); + graph.canvas.scale(ans, ans); + + //Get the canvas attributes again now that is has changed + s = graph.canvas.getSize(), + p = graph.canvas.getPos(), + ox = graph.canvas.translateOffsetX, + oy = graph.canvas.translateOffsetY, + sx = graph.canvas.scaleOffsetX, + sy = graph.canvas.scaleOffsetY; + var newX = (zoomPoint.x - p.x - s.width / 2 - ox) * (1 / sx), + newY = (zoomPoint.y - p.y - s.height / 2 - oy) * (1 / sy); + + //Translate the canvas to put the pointer back over top the same coordinate it was over before + graph.canvas.translate(newX-pointerCoordX,newY-pointerCoordY); } } diff --git a/frontend/src/patched/JIT.js b/frontend/src/patched/JIT.js index e780604e..10af6123 100644 --- a/frontend/src/patched/JIT.js +++ b/frontend/src/patched/JIT.js @@ -449,7 +449,7 @@ $.event = { isRightClick: function(e) { return (e.which == 3 || e.button == 2); }, - getPos: function(e, win) { + getPos: function(e, win, touchIndex) { // get mouse position win = win || window; e = e || win.event; @@ -457,7 +457,7 @@ $.event = { doc = doc.documentElement || doc.body; //TODO(nico): make touch event handling better if(e.touches && e.touches.length) { - e = e.touches[0]; + e = e.touches[touchIndex || 0]; } var page = { x: e.pageX || (e.clientX + doc.scrollLeft), @@ -2469,33 +2469,7 @@ Extras.Classes.Navigation = new Class({ // START METAMAPS CODE if (((ans > 1) && (5 >= this.canvas.scaleOffsetX)) || ((ans < 1) && (this.canvas.scaleOffsetX >= 0.2))) { - var s = this.canvas.getSize(), - p = this.canvas.getPos(), - ox = this.canvas.translateOffsetX, - oy = this.canvas.translateOffsetY, - sx = this.canvas.scaleOffsetX, - sy = this.canvas.scaleOffsetY; - - //Basically this is just a duplication of the Util function pixelsToCoords, it finds the canvas coordinate of the mouse pointer - var pointerCoordX = (e.pageX - p.x - s.width / 2 - ox) * (1 / sx), - pointerCoordY = (e.pageY - p.y - s.height / 2 - oy) * (1 / sy); - - //This translates the canvas to be centred over the mouse pointer, then the canvas is zoomed as intended. - this.canvas.translate(-pointerCoordX,-pointerCoordY); - this.canvas.scale(ans, ans); - - //Get the canvas attributes again now that is has changed - s = this.canvas.getSize(), - p = this.canvas.getPos(), - ox = this.canvas.translateOffsetX, - oy = this.canvas.translateOffsetY, - sx = this.canvas.scaleOffsetX, - sy = this.canvas.scaleOffsetY; - var newX = (e.pageX - p.x - s.width / 2 - ox) * (1 / sx), - newY = (e.pageY - p.y - s.height / 2 - oy) * (1 / sy); - - //Translate the canvas to put the pointer back over top the same coordinate it was over before - this.canvas.translate(newX-pointerCoordX,newY-pointerCoordY); + Metamaps.Util.zoomOnPoint(this, ans, {x: e.pageX, y: e.pageY}) } // END METAMAPS CODE @@ -2620,109 +2594,129 @@ Extras.Classes.Navigation = new Class({ Metamaps.Mouse.changeInY = 0; if((this.config.panning == 'avoid nodes' && eventInfo.getNode()) || eventInfo.getEdge()) return; this.pressed = true; - var rightClick = e.button == 2 || (navigator.platform.indexOf("Mac") != -1 && e.ctrlKey); - if (!Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) { - Metamaps.Mouse.boxStartCoordinates = eventInfo.getPos(); - } Metamaps.Mouse.didPan = false; - this.pos = eventInfo.getPos(); + var canvas = this.canvas, ox = canvas.translateOffsetX, oy = canvas.translateOffsetY, sx = canvas.scaleOffsetX, sy = canvas.scaleOffsetY; - this.pos.x *= sx; - this.pos.x += ox; - this.pos.y *= sy; - this.pos.y += oy; + + if (e.touches.length === 1) { + this.pos = eventInfo.getPos(); + } else if (e.touches.length === 2) { + var s = canvas.getSize(), + pos1 = $.event.getPos(e, win, 0), + pos2 = $.event.getPos(e, win, 1), + touch1 = { + x: (pos1.x - s.width/2 - ox) * 1/sx, + y: (pos1.y - s.height/2 - oy) * 1/sy + }, + touch2 = { + x: (pos2.x - s.width/2 - ox) * 1/sx, + y: (pos2.y - s.height/2 - oy) * 1/sy + }; + this.pos = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2 + } + this.unitRadius = Metamaps.Util.getDistance(touch1, touch2) / 2 + } + if (e.touches.length === 1 || e.touches.length === 2) { + this.pos.x *= sx; + this.pos.x += ox; + this.pos.y *= sy; + this.pos.y += oy; + } }, onTouchMove: function(e, win, eventInfo) { + e.preventDefault() if(!this.config.panning) return; if(!this.pressed) return; - if(this.config.panning == 'avoid nodes' && (this.dom? this.isLabel(e, win) : eventInfo.getNode())) return; + var canvas = this.canvas, + ox = canvas.translateOffsetX, + oy = canvas.translateOffsetY, + sx = canvas.scaleOffsetX, + sy = canvas.scaleOffsetY, + beforePos = this.pos, + currentPos, + touch1, + touch2; + if (e.touches.length == 1) { - var rightClick = e.button == 2 || (navigator.platform.indexOf("Mac") != -1 && e.ctrlKey); - if (!Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) { - Metamaps.Visualize.mGraph.busy = true; - Metamaps.boxStartCoordinates = eventInfo.getPos(); - return; + currentPos = eventInfo.getPos() + } else if (e.touches.length >= 2) { + var s = canvas.getSize(), + pos1 = $.event.getPos(e, win, 0), + pos2 = $.event.getPos(e, win, 1), + touch1 = { + x: (pos1.x - s.width/2 - ox) * 1/sx, + y: (pos1.y - s.height/2 - oy) * 1/sy + }, + touch2 = { + x: (pos2.x - s.width/2 - ox) * 1/sx, + y: (pos2.y - s.height/2 - oy) * 1/sy + }; + currentPos = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2 } - if (Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) { - Metamaps.Visualize.mGraph.busy = true; - Metamaps.JIT.drawSelectBox(eventInfo,e); - return; - } - if (rightClick){ - return; - } - if (e.target.id != 'infovis-canvas') { - this.pressed = false; - return; - } - Metamaps.Mouse.didPan = true; - var thispos = this.pos, - currentPos = eventInfo.getPos(), - canvas = this.canvas, - ox = canvas.translateOffsetX, - oy = canvas.translateOffsetY, - sx = canvas.scaleOffsetX, - sy = canvas.scaleOffsetY; - currentPos.x *= sx; - currentPos.y *= sy; - currentPos.x += ox; - currentPos.y += oy; - var x = currentPos.x - thispos.x, - y = currentPos.y - thispos.y; - Metamaps.Mouse.changeInX = x; - Metamaps.Mouse.changeInY = y; - this.pos = currentPos; - this.canvas.translate(x * 1/sx, y * 1/sy); - jQuery(document).trigger(Metamaps.JIT.events.pan); - } - /* - else if (e.touches.length == 2) { - var touch1 = e.touches[0] - var touch2 = e.touches[1] - var canvas = this.canvas - - callCount++; - - var dist = Metamaps.Util.getDistance({ - x: touch1.clientX, - y: touch1.clientY + } + currentPos.x *= sx; + currentPos.y *= sy; + currentPos.x += ox; + currentPos.y += oy; + Metamaps.Mouse.didPan = true; + var x = currentPos.x - beforePos.x, + y = currentPos.y - beforePos.y; + Metamaps.Mouse.changeInX = x; + Metamaps.Mouse.changeInY = y; + this.pos = currentPos; + canvas.translate(x * 1/sx, y * 1/sy); + jQuery(document).trigger(Metamaps.JIT.events.pan); + + if (e.touches.length >= 2) { + var currentPixelRadius = Metamaps.Util.getDistance({ + x: e.touches[0].clientX, + y: e.touches[0].clientY }, { - x: touch2.clientX, - y: touch2.clientY - }) - - if (!this.initDist) { - this.initDist = dist - this.initScale = canvas.scaleOffsetX + x: e.touches[1].clientX, + y: e.touches[1].clientY + }) / 2 + var desiredScale = currentPixelRadius / this.unitRadius + var scaler = desiredScale / sx + var midpoint = { + x: (e.touches[0].clientX + e.touches[1].clientX) / 2, + y: (e.touches[0].clientY + e.touches[1].clientY) / 2 } - var scale = (dist / this.initDist) - - document.getElementById("header_content").innerHTML = scale + ' ' + canvas.scaleOffsetX - if (30 >= this.initScale * scale && this.initScale * scale >= 0.2) { - canvas.scale(this.initScale * scale, this.initScale * scale) + if (30 >= desiredScale && desiredScale >= 0.2) { + //canvas.scale(scaler, scaler) + Metamaps.Util.zoomOnPoint(this, scaler, midpoint) } - if (canvas.scaleOffsetX < 0.5) { - canvas.viz.labels.hideLabels(true) - } else if (canvas.scaleOffsetX > 0.5) { - canvas.viz.labels.hideLabels(false) - } - jQuery(document).trigger(Metamaps.JIT.events.zoom); } - */ }, onTouchEnd: function(e, win, eventInfo, isRightClick) { if(!this.config.panning) return; this.pressed = false; - if (Metamaps.Mouse.didPan) Metamaps.JIT.SmoothPanning(); - this.initDist = false + if (e.touches.length === 1) { + var canvas = this.canvas, + ox = canvas.translateOffsetX, + oy = canvas.translateOffsetY, + sx = canvas.scaleOffsetX, + sy = canvas.scaleOffsetY + s = canvas.getSize(); + this.pos = { + x: (e.touches[0].clientX - s.width/2 - ox) * 1/sx, + y: (e.touches[0].clientY - s.height/2 - oy) * 1/sy + }; + } else if (e.touches.length === 0) { + this.pos = null + if (Metamaps.Mouse.didPan) Metamaps.JIT.SmoothPanning(); + } } // END METAMAPS CODE }); From ddbaac513ff43e1459c02f58e6e281840a597ef7 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Thu, 2 Mar 2017 04:35:13 +0000 Subject: [PATCH 14/32] fix drop from two touches to one --- app/services/activity_service.rb | 18 ++++++++++++++++++ frontend/src/patched/JIT.js | 11 +++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 app/services/activity_service.rb diff --git a/app/services/activity_service.rb b/app/services/activity_service.rb new file mode 100644 index 00000000..9d3009ff --- /dev/null +++ b/app/services/activity_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +class ActivityService + + def self.send_activity_for_followers(map, reason_filter = nil) + follows = FollowService.get_follows_for_entity(map, nil, reason_filter) + + # generate the full set of activity here + + follows.each{|follow| + # check here whether this person needs to receive anything + # related to the activity that occurred on the map + + body = NotificationService.renderer.render(template: 'template', locals: {}, layout: false) + follow.user.notify('subject', body, event, false, MAP_ACTIVITY, follow.user.emails_allowed, ??) + } + end + +end \ No newline at end of file diff --git a/frontend/src/patched/JIT.js b/frontend/src/patched/JIT.js index 10af6123..5ea04ce1 100644 --- a/frontend/src/patched/JIT.js +++ b/frontend/src/patched/JIT.js @@ -2692,28 +2692,31 @@ Extras.Classes.Navigation = new Class({ y: (e.touches[0].clientY + e.touches[1].clientY) / 2 } if (30 >= desiredScale && desiredScale >= 0.2) { - //canvas.scale(scaler, scaler) Metamaps.Util.zoomOnPoint(this, scaler, midpoint) + jQuery(document).trigger(Metamaps.JIT.events.zoom) } - jQuery(document).trigger(Metamaps.JIT.events.zoom); } }, onTouchEnd: function(e, win, eventInfo, isRightClick) { if(!this.config.panning) return; - this.pressed = false; if (e.touches.length === 1) { var canvas = this.canvas, ox = canvas.translateOffsetX, oy = canvas.translateOffsetY, sx = canvas.scaleOffsetX, - sy = canvas.scaleOffsetY + sy = canvas.scaleOffsetY, s = canvas.getSize(); this.pos = { x: (e.touches[0].clientX - s.width/2 - ox) * 1/sx, y: (e.touches[0].clientY - s.height/2 - oy) * 1/sy }; + this.pos.x *= sx; + this.pos.x += ox; + this.pos.y *= sy; + this.pos.y += oy; } else if (e.touches.length === 0) { + this.pressed = false; this.pos = null if (Metamaps.Mouse.didPan) Metamaps.JIT.SmoothPanning(); } From 529dec09a3efeea30f8c780780e6baf13ff6a751 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Thu, 2 Mar 2017 04:38:33 +0000 Subject: [PATCH 15/32] don't commit activity service --- app/services/activity_service.rb | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 app/services/activity_service.rb diff --git a/app/services/activity_service.rb b/app/services/activity_service.rb deleted file mode 100644 index 9d3009ff..00000000 --- a/app/services/activity_service.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -class ActivityService - - def self.send_activity_for_followers(map, reason_filter = nil) - follows = FollowService.get_follows_for_entity(map, nil, reason_filter) - - # generate the full set of activity here - - follows.each{|follow| - # check here whether this person needs to receive anything - # related to the activity that occurred on the map - - body = NotificationService.renderer.render(template: 'template', locals: {}, layout: false) - follow.user.notify('subject', body, event, false, MAP_ACTIVITY, follow.user.emails_allowed, ??) - } - end - -end \ No newline at end of file From a6c1c0c730f1608baaa4467bd84ee56b704b00a2 Mon Sep 17 00:00:00 2001 From: Devin Howard <devin@callysto.com> Date: Sun, 5 Mar 2017 00:51:51 +0800 Subject: [PATCH 16/32] ability to select/unselect all metacodes in custom set with keyboard shortcut (fix #390) (#1078) * ability to select/unselect all metacodes in custom set with keyboard shortcut * select all button * nicer all/none buttons --- app/assets/stylesheets/application.scss.erb | 62 +++++++++++++-------- app/views/shared/_switchmetacodes.html.erb | 6 +- frontend/src/Metamaps/Create.js | 43 +++++++++++++- frontend/src/Metamaps/Listeners.js | 7 ++- 4 files changed, 92 insertions(+), 26 deletions(-) diff --git a/app/assets/stylesheets/application.scss.erb b/app/assets/stylesheets/application.scss.erb index b16983df..2045628c 100644 --- a/app/assets/stylesheets/application.scss.erb +++ b/app/assets/stylesheets/application.scss.erb @@ -2311,6 +2311,9 @@ and it won't be important on password protected instances */ } /* switch metacode set */ +#switchMetacodes > p { + margin: 16px 0 16px 0; +} #metacodeSwitchTabs { width: 100%; font-size: 17px; @@ -2318,28 +2321,43 @@ and it won't be important on password protected instances */ border: none; background: none; padding: 0; -} -#metacodeSwitchTabs .setDesc { - margin-bottom: 5px; - font-family: 'din-medium', helvetica, sans-serif; - color: #424242; - font-size: 14px; - text-align: justify; - padding-right: 16px; -} -#switchMetacodes > p { - margin: 16px 0 16px 0; -} -#metacodeSwitchTabs > ul { - width: 130px; -} -#metacodeSwitchTabs > ul li { - font-size: 14px; - text-transform: uppercase; -} -#metacodeSwitchTabs li.ui-state-active a { - color: #00BCD4; - cursor: pointer; + + .setDesc, + .selectAll, + .selectNone { + margin-bottom: 5px; + font-family: 'din-medium', helvetica, sans-serif; + color: #424242; + font-size: 14px; + text-align: justify; + padding-right: 16px; + display: inline-block; + } + + .selectAll, + .selectNone { + float: right; + cursor: pointer; + + &:hover, + &.selected { + color: #00bcd4; + } + } + + & > ul { + width: 130px; + + li { + font-size: 14px; + text-transform: uppercase; + } + } + + li.ui-state-active a { + color: #00BCD4; + cursor: pointer; + } } .metacodeSwitchTab { max-height: 300px; diff --git a/app/views/shared/_switchmetacodes.html.erb b/app/views/shared/_switchmetacodes.html.erb index b5607065..9dbdabb6 100644 --- a/app/views/shared/_switchmetacodes.html.erb +++ b/app/views/shared/_switchmetacodes.html.erb @@ -91,7 +91,9 @@ </div> <% end %> <div id="metacodeSwitchTabsCustom"> - <p class="setDesc">Choose Your Metacodes</p> + <div class="setDesc">Choose Your Metacodes</div> + <div class="selectNone">NONE</div> + <div class="selectAll">ALL</div> <% @list = '' %> <% metacodesInUse = user_metacodes() %> <% Metacode.order("name").all.each_with_index do |m, index| %> @@ -116,4 +118,4 @@ <script> Metamaps.Create.selectedMetacodeSet = "metacodeset-<%= selectedSet %>" Metamaps.Create.selectedMetacodeSetIndex = <%= index %> -</script> \ No newline at end of file +</script> diff --git a/frontend/src/Metamaps/Create.js b/frontend/src/Metamaps/Create.js index 466900e5..b01642c5 100644 --- a/frontend/src/Metamaps/Create.js +++ b/frontend/src/Metamaps/Create.js @@ -28,6 +28,8 @@ const Create = { }).addClass('ui-tabs-vertical ui-helper-clearfix') $('#metacodeSwitchTabs .ui-tabs-nav li').removeClass('ui-corner-top').addClass('ui-corner-left') $('.customMetacodeList li').click(self.toggleMetacodeSelected) // within the custom metacode set tab + $('.selectAll').click(self.metacodeSelectorSelectAll) + $('.selectNone').click(self.metacodeSelectorSelectNone) }, toggleMetacodeSelected: function() { var self = Create @@ -43,6 +45,46 @@ const Create = { self.newSelectedMetacodes.push($(this).attr('id')) self.newSelectedMetacodeNames.push($(this).attr('data-name')) } + self.updateSelectAllColors() + }, + updateSelectAllColors: function() { + $('.selectAll, .selectNone').removeClass('selected') + if (Create.metacodeSelectorAreAllSelected()) { + $('.selectAll').addClass('selected') + } else if (Create.metacodeSelectorAreNoneSelected()) { + $('.selectNone').addClass('selected') + } + }, + metacodeSelectorSelectAll: function() { + $('.customMetacodeList li.toggledOff').each(Create.toggleMetacodeSelected) + Create.updateSelectAllColors() + }, + metacodeSelectorSelectNone: function() { + $('.customMetacodeList li').not('.toggledOff').each(Create.toggleMetacodeSelected) + Create.updateSelectAllColors() + }, + metacodeSelectorAreAllSelected: function() { + return $('.customMetacodeList li').toArray() + .map(li => !$(li).is('.toggledOff')) // note the ! on this line + .reduce((curr, prev) => curr && prev) + }, + metacodeSelectorAreNoneSelected: function() { + return $('.customMetacodeList li').toArray() + .map(li => $(li).is('.toggledOff')) + .reduce((curr, prev) => curr && prev) + }, + metacodeSelectorToggleSelectAll: function() { + // should be called when Create.isSwitchingSet is true and .customMetacodeList is visible + if (!Create.isSwitchingSet) return + if (!$('.customMetacodeList').is(':visible')) return + + // If all are selected, then select none. Otherwise, select all. + if (Create.metacodeSelectorAreAllSelected()) { + Create.metacodeSelectorSelectNone() + } else { + // if some, but not all, are selected, it still runs this function + Create.metacodeSelectorSelectAll() + } }, updateMetacodeSet: function(set, index, custom) { if (custom && Create.newSelectedMetacodes.length === 0) { @@ -114,7 +156,6 @@ const Create = { } }) }, - cancelMetacodeSetSwitch: function() { var self = Create self.isSwitchingSet = false diff --git a/frontend/src/Metamaps/Listeners.js b/frontend/src/Metamaps/Listeners.js index c3b644df..ea34f396 100644 --- a/frontend/src/Metamaps/Listeners.js +++ b/frontend/src/Metamaps/Listeners.js @@ -1,6 +1,7 @@ /* global $ */ import Active from './Active' +import Create from './Create' import Control from './Control' import DataModel from './DataModel' import JIT from './JIT' @@ -35,7 +36,11 @@ const Listeners = { Control.deleteSelected() break case 65: // if a or A is pressed - if ((e.ctrlKey || e.metaKey) && onCanvas) { + if (Create.isSwitchingSet && e.ctrlKey || e.metaKey) { + Create.metacodeSelectorToggleSelectAll() + e.preventDefault() + break + } else if ((e.ctrlKey || e.metaKey) && onCanvas) { const nodesCount = Object.keys(Visualize.mGraph.graph.nodes).length const selectedNodesCount = Selected.Nodes.length e.preventDefault() From 4ff96198377ce58cefdca26cf2302dc99cb27005 Mon Sep 17 00:00:00 2001 From: Devin Howard <devin@callysto.com> Date: Mon, 6 Mar 2017 02:29:12 +0800 Subject: [PATCH 17/32] set up react testing (#1080) * install mocha-webpack. also switch hark to npm version instead of github version * well, mocha-webpack runs * add jsdom for tests * upgrade to webpack 2 * fix npm run test errors * ImportDialogBox component tests --- .gitignore | 1 + .../src/Metamaps/GlobalUI/ImportDialog.js | 5 +- frontend/src/components/ImportDialogBox.js | 21 ++------ .../Import.spec.js} | 0 .../Util.spec.js} | 0 .../test/components/ImportDialogBox.spec.js | 50 +++++++++++++++++++ frontend/test/support/dom.js | 25 ++++++++++ package.json | 15 +++--- webpack.config.js | 13 +++-- webpack.test.config.js | 5 ++ 10 files changed, 108 insertions(+), 27 deletions(-) rename frontend/test/{Metamaps.Import.spec.js => Metamaps/Import.spec.js} (100%) rename frontend/test/{Metamaps.Util.spec.js => Metamaps/Util.spec.js} (100%) create mode 100644 frontend/test/components/ImportDialogBox.spec.js create mode 100644 frontend/test/support/dom.js create mode 100644 webpack.test.config.js diff --git a/.gitignore b/.gitignore index bf50d518..de3cc231 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ app/assets/javascripts/webpacked # Ignore all logfiles and tempfiles. log/*.log tmp +.tmp coverage diff --git a/frontend/src/Metamaps/GlobalUI/ImportDialog.js b/frontend/src/Metamaps/GlobalUI/ImportDialog.js index 1428ab6d..31913ea6 100644 --- a/frontend/src/Metamaps/GlobalUI/ImportDialog.js +++ b/frontend/src/Metamaps/GlobalUI/ImportDialog.js @@ -26,7 +26,10 @@ const ImportDialog = { ReactDOM.render(React.createElement(ImportDialogBox, { onFileAdded: PasteInput.handleFile, exampleImageUrl: serverData['import-example.png'], - downloadScreenshot: ImportDialog.downloadScreenshot + downloadScreenshot: ImportDialog.downloadScreenshot, + onExport: format => { + window.open(`${window.location.pathname}/export.${format}`, '_blank') + } }), $('.importDialogWrapper').get(0)) }, show: function() { diff --git a/frontend/src/components/ImportDialogBox.js b/frontend/src/components/ImportDialogBox.js index 6c2d7413..9a9c777b 100644 --- a/frontend/src/components/ImportDialogBox.js +++ b/frontend/src/components/ImportDialogBox.js @@ -2,17 +2,6 @@ import React, { PropTypes, Component } from 'react' import Dropzone from 'react-dropzone' class ImportDialogBox extends Component { - constructor(props) { - super(props) - - this.state = { - } - } - - handleExport = format => () => { - window.open(`${window.location.pathname}/export.${format}`, '_blank') - } - handleFile = (files, e) => { e.preventDefault() // prevent it from triggering the default drag-drop handler this.props.onFileAdded(files[0]) @@ -22,13 +11,13 @@ class ImportDialogBox extends Component { return ( <div className="import-dialog"> <h3>EXPORT</h3> - <div className="import-blue-button" onClick={this.handleExport('csv')}> + <div className="export-csv import-blue-button" onClick={this.props.onExport('csv')}> Export as CSV </div> - <div className="import-blue-button" onClick={this.handleExport('json')}> + <div className="export-json import-blue-button" onClick={this.props.onExport('json')}> Export as JSON </div> - <div className="import-blue-button" onClick={this.props.downloadScreenshot}> + <div className="download-screenshot import-blue-button" onClick={this.props.downloadScreenshot}> Download screenshot </div> <h3>IMPORT</h3> @@ -46,8 +35,8 @@ class ImportDialogBox extends Component { ImportDialogBox.propTypes = { onFileAdded: PropTypes.func, - exampleImageUrl: PropTypes.string, - downloadScreenshot: PropTypes.func + downloadScreenshot: PropTypes.func, + onExport: PropTypes.func } export default ImportDialogBox diff --git a/frontend/test/Metamaps.Import.spec.js b/frontend/test/Metamaps/Import.spec.js similarity index 100% rename from frontend/test/Metamaps.Import.spec.js rename to frontend/test/Metamaps/Import.spec.js diff --git a/frontend/test/Metamaps.Util.spec.js b/frontend/test/Metamaps/Util.spec.js similarity index 100% rename from frontend/test/Metamaps.Util.spec.js rename to frontend/test/Metamaps/Util.spec.js diff --git a/frontend/test/components/ImportDialogBox.spec.js b/frontend/test/components/ImportDialogBox.spec.js new file mode 100644 index 00000000..f14e04b3 --- /dev/null +++ b/frontend/test/components/ImportDialogBox.spec.js @@ -0,0 +1,50 @@ +/* global describe, it */ +import React from 'react' +import TestUtils from 'react-addons-test-utils' // ES6 +import ImportDialogBox from '../../src/components/ImportDialogBox.js' +import Dropzone from 'react-dropzone' +import chai from 'chai' + +const { expect } = chai + +describe('ImportDialogBox', function() { + it('has an Export CSV button', function(done) { + const onExport = format => { + if (format === 'csv') done() + } + const detachedComp = TestUtils.renderIntoDocument(<ImportDialogBox onExport={onExport} />) + const button = TestUtils.findRenderedDOMComponentWithClass(detachedComp, 'export-csv') + const buttonNode = React.findDOMNode(button) + expect(button).to.exist; + TestUtils.Simulate.click(buttonNode) + }) + + it('has an Export JSON button', function(done) { + const onExport = format => { + if (format === 'json') done() + } + const detachedComp = TestUtils.renderIntoDocument(<ImportDialogBox onExport={onExport} />) + const button = TestUtils.findRenderedDOMComponentWithClass(detachedComp, 'export-json') + const buttonNode = React.findDOMNode(button) + expect(button).to.exist; + TestUtils.Simulate.click(buttonNode) + }) + + it('has a Download screenshot button', function(done) { + const downloadScreenshot = () => { done() } + const detachedComp = TestUtils.renderIntoDocument(<ImportDialogBox downloadScreenshot={downloadScreenshot()} />) + const button = TestUtils.findRenderedDOMComponentWithClass(detachedComp, 'download-screenshot') + const buttonNode = React.findDOMNode(button) + expect(button).to.exist; + TestUtils.Simulate.click(buttonNode) + }) + + it('has a file uploader', function(done) { + const uploadedFile = { file: 'mock a file' } + const onFileAdded = file => { if (file === uploadedFile) done() } + const detachedComp = TestUtils.renderIntoDocument(<ImportDialogBox onExport={() => {}} onFileAdded={onFileAdded} />) + const dropzone = TestUtils.findRenderedComponentWithType(detachedComp, Dropzone) + expect(dropzone).to.exist; + dropzone.props.onDropAccepted([uploadedFile], { preventDefault: () => {} }) + }) +}) diff --git a/frontend/test/support/dom.js b/frontend/test/support/dom.js new file mode 100644 index 00000000..af2c1bf9 --- /dev/null +++ b/frontend/test/support/dom.js @@ -0,0 +1,25 @@ +const jsdom = require('jsdom') +const doc = jsdom.jsdom('<!doctype html><html><body></body></html>') +const win = doc.defaultView + +global.document = doc +global.window = win + +// take all properties of the window object and also attach it to the +// mocha global object +propagateToGlobal(win) + +// from mocha-jsdom https://github.com/rstacruz/mocha-jsdom/blob/master/index.js#L80 +function propagateToGlobal (window) { + for (let key in window) { + if (!window.hasOwnProperty(key)) continue + if (key in global) continue + + global[key] = window[key] + } +} + +// Metamaps dependencies fixes +global.HowlerGlobal = global.HowlerGlobal || { prototype: {} } +global.Howl = global.Howl || { prototype: {} } +global.Sound = global.Sound || { prototype: {} } diff --git a/package.json b/package.json index 6db62390..84eff416 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "webpack", "build:watch": "webpack --watch", - "test": "mocha --compilers js:babel-core/register frontend/test", + "test": "mocha-webpack --webpack-config webpack.test.config.js --require frontend/test/support/dom.js frontend/test", "eslint": "eslint frontend", "eslint:fix": "eslint --fix frontend" }, @@ -35,7 +35,7 @@ "csv-parse": "1.1.10", "emoji-mart": "0.3.7", "getscreenmedia": "2.0.0", - "hark": "git://github.com/otalk/hark#342ef9b7eff2", + "hark": "1.1.5", "howler": "2.0.2", "jquery": "3.1.1", "json-loader": "0.5.4", @@ -45,12 +45,12 @@ "react": "15.4.2", "react-dom": "15.4.2", "react-dropzone": "3.9.1", - "react-onclickoutside": "^5.9.0", + "react-onclickoutside": "5.9.0", "redux": "3.6.0", - "riek": "^1.0.7", + "riek": "1.0.7", "simplewebrtc": "2.2.2", "socket.io": "1.3.7", - "webpack": "1.14.0" + "webpack": "2.2.1" }, "devDependencies": { "babel-eslint": "^7.1.1", @@ -61,7 +61,10 @@ "eslint-plugin-promise": "^3.4.0", "eslint-plugin-react": "^6.8.0", "eslint-plugin-standard": "^2.0.1", - "mocha": "^3.2.0" + "jsdom": "^9.11.0", + "mocha": "^3.2.0", + "mocha-webpack": "^0.7.0", + "react-addons-test-utils": "^15.4.2" }, "optionalDependencies": { "raml2html": "4.0.5" diff --git a/webpack.config.js b/webpack.config.js index 13cf6a65..a207d23c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,8 +5,12 @@ const NODE_ENV = process.env.NODE_ENV || 'development' const plugins = [ new webpack.DefinePlugin({ "process.env.NODE_ENV": `"${NODE_ENV}"` - }) + }), + new webpack.IgnorePlugin(/^mock-firmata$/), // work around bindings.js error + new webpack.ContextReplacementPlugin(/bindings$/, /^$/) // work around bindings.js error ] +const externals = ["bindings"] // work around bindings.js error + if (NODE_ENV === 'production') { plugins.push(new webpack.optimize.DedupePlugin()) plugins.push(new webpack.optimize.UglifyJsPlugin({ @@ -26,12 +30,13 @@ const devtool = NODE_ENV === 'production' ? undefined : 'cheap-module-eval-sourc module.exports = { context: __dirname, plugins, + externals, devtool, module: { - preLoaders: [ - { test: /\.json$/, loader: 'json' } - ], loaders: [ + { + test: /\.json$/, loader: 'json-loader' + }, { test: /\.(js|jsx)?$/, exclude: /node_modules/, diff --git a/webpack.test.config.js b/webpack.test.config.js new file mode 100644 index 00000000..518835d4 --- /dev/null +++ b/webpack.test.config.js @@ -0,0 +1,5 @@ +const config = require('./webpack.config') + +config.target = 'node' + +module.exports = config From 153cc38d1a6b5aa632a2316e0e34bcf684eb2708 Mon Sep 17 00:00:00 2001 From: Robert Best <chessscholar@gmail.com> Date: Mon, 6 Mar 2017 11:48:59 -0500 Subject: [PATCH 18/32] Fixes bug where pressing delete key while editing text will suggest... (#1083) * Fixes bug where pressing delete key while editing text will suggest the deletion of selected map entities * Changed the DEL key to remove entities instead of delete them --- frontend/src/Metamaps/Listeners.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/Metamaps/Listeners.js b/frontend/src/Metamaps/Listeners.js index ea34f396..d55abf92 100644 --- a/frontend/src/Metamaps/Listeners.js +++ b/frontend/src/Metamaps/Listeners.js @@ -32,8 +32,11 @@ const Listeners = { JIT.escKeyHandler() break case 46: // if DEL is pressed - e.preventDefault() - Control.deleteSelected() + if(e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA" && (Selected.Nodes.length + Selected.Edges.length) > 0){ + e.preventDefault() + Control.removeSelectedNodes() + Control.removeSelectedEdges() + } break case 65: // if a or A is pressed if (Create.isSwitchingSet && e.ctrlKey || e.metaKey) { From 9df389060e008c7c7423f868ebf0af5a1e6fbe77 Mon Sep 17 00:00:00 2001 From: Devin Howard <devin@callysto.com> Date: Mon, 6 Mar 2017 09:29:36 -0800 Subject: [PATCH 19/32] temporarily disable code climate duplication engine --- .codeclimate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.codeclimate.yml b/.codeclimate.yml index a187069d..3e9ab5f8 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -5,7 +5,7 @@ engines: bundler-audit: enabled: true duplication: - enabled: true + enabled: false config: languages: count_threshold: 3 # rule of three From 55b031ccb7433ae7d6e46088901a11e6a5413a7b Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Tue, 7 Mar 2017 01:47:10 +0000 Subject: [PATCH 20/32] add topic following for internal testing --- app/assets/stylesheets/application.scss.erb | 10 ++++++++++ frontend/src/Metamaps/Views/TopicCard.js | 15 +++++++++++++++ frontend/src/components/TopicCard/Follow.js | 17 +++++++++++++++++ frontend/src/components/TopicCard/index.js | 8 +++++++- 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/TopicCard/Follow.js diff --git a/app/assets/stylesheets/application.scss.erb b/app/assets/stylesheets/application.scss.erb index 2045628c..0c7b9976 100644 --- a/app/assets/stylesheets/application.scss.erb +++ b/app/assets/stylesheets/application.scss.erb @@ -3139,3 +3139,13 @@ script.data-gratipay-username { .inline { display: inline-block; } + +.topicFollow { + text-align: center; + height: 48px; + line-height: 48px; + border-top: 1px solid #BDBDBD; + background: #FFF; + cursor: pointer; + font-family: din-regular; +} \ No newline at end of file diff --git a/frontend/src/Metamaps/Views/TopicCard.js b/frontend/src/Metamaps/Views/TopicCard.js index 51036685..0b02fccd 100644 --- a/frontend/src/Metamaps/Views/TopicCard.js +++ b/frontend/src/Metamaps/Views/TopicCard.js @@ -5,6 +5,7 @@ import ReactDOM from 'react-dom' import Active from '../Active' import Visualize from '../Visualize' +import GlobalUI from '../GlobalUI' import ReactTopicCard from '../../components/TopicCard' @@ -24,6 +25,20 @@ const TopicCard = { updateTopic: obj => { topic.save(obj, { success: topic => self.populateShowCard(topic) }) }, + onFollow: () => { + const isFollowing = topic.isFollowedBy(Active.Mapper) + $.post({ + url: `/topics/${topic.id}/${isFollowing ? 'un' : ''}follow` + }) + if (isFollowing) { + GlobalUI.notifyUser('You are no longer following this topic') + Active.Mapper.unfollowTopic(topic.id) + } else { + GlobalUI.notifyUser('You are now following this topic') + Active.Mapper.followTopic(topic.id) + } + self.populateShowCard(topic) + }, metacodeSets: self.metacodeSets, redrawCanvas: () => { Visualize.mGraph.plot() diff --git a/frontend/src/components/TopicCard/Follow.js b/frontend/src/components/TopicCard/Follow.js new file mode 100644 index 00000000..786001d7 --- /dev/null +++ b/frontend/src/components/TopicCard/Follow.js @@ -0,0 +1,17 @@ +import React, { PropTypes, Component } from 'react' + +class Follow extends Component { + render = () => { + const { isFollowing, onFollow } = this.props + return <div className='topicFollow' onClick={onFollow}> + {isFollowing ? 'Unfollow' : 'Follow'} + </div> + } +} + +Follow.propTypes = { + isFollowing: PropTypes.bool, + onFollow: PropTypes.func +} + +export default Follow diff --git a/frontend/src/components/TopicCard/index.js b/frontend/src/components/TopicCard/index.js index 3ebe700a..2c30d45e 100644 --- a/frontend/src/components/TopicCard/index.js +++ b/frontend/src/components/TopicCard/index.js @@ -4,11 +4,15 @@ import Title from './Title' import Links from './Links' import Desc from './Desc' import Attachments from './Attachments' +import Follow from './Follow' +import Util from '../../Metamaps/Util' + class ReactTopicCard extends Component { render = () => { - const { topic, ActiveMapper } = this.props + const { topic, ActiveMapper, onFollow } = this.props const authorizedToEdit = topic.authorizeToEdit(ActiveMapper) + const isFollowing = topic.isFollowedBy(ActiveMapper) const hasAttachment = topic.get('link') && topic.get('link') !== '' let classname = 'permission' @@ -40,6 +44,7 @@ class ReactTopicCard extends Component { authorizedToEdit={authorizedToEdit} updateTopic={this.props.updateTopic} /> + {Util.isTester(ActiveMapper) && <Follow isFollowing={isFollowing} onFollow={onFollow} />} <div className="clearfloat"></div> </div> </div> @@ -51,6 +56,7 @@ ReactTopicCard.propTypes = { topic: PropTypes.object, ActiveMapper: PropTypes.object, updateTopic: PropTypes.func, + onFollow: PropTypes.func, metacodeSets: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, metacodes: PropTypes.arrayOf(PropTypes.shape({ From b740fef8fe19b57452efa0661d4fc6a29e7819f7 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Mon, 6 Mar 2017 22:42:22 -0500 Subject: [PATCH 21/32] daily map activity emails (#1081) * data prepared, task setup * add the basics of the email template * cover granular permissions * unfollow this map * break out permissions tests better * rename so test runs --- Gemfile | 2 + Gemfile.lock | 7 +- app/mailers/application_mailer.rb | 4 - app/mailers/map_activity_mailer.rb | 11 + app/models/mapping.rb | 7 +- app/models/synapse.rb | 8 +- app/services/map_activity_service.rb | 98 +++++ .../daily_summary.html.erb | 57 +++ .../shared/_mailer_unsubscribe_link.html.erb | 2 +- config/initializers/mailboxer.rb | 2 - lib/tasks/emails.rake | 20 + spec/factories/message.rb | 8 + spec/mailers/map_activity_mailer_spec.rb | 6 + .../previews/map_activity_mailer_preview.rb | 109 +++++ spec/services/map_activity_service_spec.rb | 398 ++++++++++++++++++ 15 files changed, 724 insertions(+), 15 deletions(-) create mode 100644 app/mailers/map_activity_mailer.rb create mode 100644 app/services/map_activity_service.rb create mode 100644 app/views/map_activity_mailer/daily_summary.html.erb create mode 100644 lib/tasks/emails.rake create mode 100644 spec/factories/message.rb create mode 100644 spec/mailers/map_activity_mailer_spec.rb create mode 100644 spec/mailers/previews/map_activity_mailer_preview.rb create mode 100644 spec/services/map_activity_service_spec.rb diff --git a/Gemfile b/Gemfile index cf3eecb1..36426058 100644 --- a/Gemfile +++ b/Gemfile @@ -51,4 +51,6 @@ group :development, :test do gem 'pry-rails' gem 'rubocop' gem 'tunemygc' + gem 'faker' + gem 'timecop' end diff --git a/Gemfile.lock b/Gemfile.lock index fd414eb2..62e6e8c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,6 +109,8 @@ GEM factory_girl_rails (4.8.0) factory_girl (~> 4.8.0) railties (>= 3.0.0) + faker (1.7.3) + i18n (~> 0.5) globalid (0.3.7) activesupport (>= 4.1.0) httparty (0.14.0) @@ -272,6 +274,7 @@ GEM thor (0.19.4) thread_safe (0.3.5) tilt (2.0.5) + timecop (0.8.1) tunemygc (1.0.69) tzinfo (1.2.2) thread_safe (~> 0.1) @@ -301,6 +304,7 @@ DEPENDENCIES dotenv-rails exception_notification factory_girl_rails + faker httparty jquery-rails jquery-ui-rails @@ -327,6 +331,7 @@ DEPENDENCIES slack-notifier snorlax sucker_punch + timecop tunemygc uglifier @@ -334,4 +339,4 @@ RUBY VERSION ruby 2.3.0p0 BUNDLED WITH - 1.13.7 + 1.14.6 diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 112b28ab..fdd2fa85 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -3,10 +3,6 @@ class ApplicationMailer < ActionMailer::Base default from: 'team@metamaps.cc' layout 'mailer' - def deliver - raise NotImplementedError('Please use Mailboxer to send your emails.') - end - class << self def mail_for_notification(notification) case notification.notification_code diff --git a/app/mailers/map_activity_mailer.rb b/app/mailers/map_activity_mailer.rb new file mode 100644 index 00000000..977ece4f --- /dev/null +++ b/app/mailers/map_activity_mailer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +class MapActivityMailer < ApplicationMailer + default from: 'team@metamaps.cc' + + def daily_summary(user, map, summary_data) + @user = user + @map = map + @summary_data = summary_data + mail(to: user.email, subject: MapActivityService.subject_line(map)) + end +end diff --git a/app/models/mapping.rb b/app/models/mapping.rb index f8430bde..a49555a3 100644 --- a/app/models/mapping.rb +++ b/app/models/mapping.rb @@ -41,10 +41,11 @@ class Mapping < ApplicationRecord topic2: mappable.topic2.filtered, mapping_id: id ) - Events::SynapseAddedToMap.publish!(mappable, map, user, nil) + meta = { 'mapping_id': id } + Events::SynapseAddedToMap.publish!(mappable, map, user, meta) end end - + def after_created_async FollowService.follow(map, user, 'contributed') end @@ -57,7 +58,7 @@ class Mapping < ApplicationRecord ActionCable.server.broadcast 'map_' + map.id.to_s, type: 'topicMoved', id: mappable.id, mapping_id: id, x: xloc, y: yloc end end - + def after_updated_async if (mappable_type == 'Topic') && (xloc_changed? || yloc_changed?) FollowService.follow(map, updated_by, 'contributed') diff --git a/app/models/synapse.rb b/app/models/synapse.rb index e8378f0e..4def4147 100644 --- a/app/models/synapse.rb +++ b/app/models/synapse.rb @@ -68,13 +68,13 @@ class Synapse < ApplicationRecord output += %(\n) output end - + protected - + def set_perm_by_defer permission = defer_to_map.permission if defer_to_map end - + def after_created_async follow_ids = NotificationService.notify_followers(topic1, TOPIC_CONNECTED_1, self) NotificationService.notify_followers(topic2, TOPIC_CONNECTED_2, self, nil, follow_ids) @@ -93,7 +93,7 @@ class Synapse < ApplicationRecord end end end - + def before_destroyed # hard to know how to do this yet, because the synapse actually gets destroyed #NotificationService.notify_followers(topic1, 'topic_disconnected', self) diff --git a/app/services/map_activity_service.rb b/app/services/map_activity_service.rb new file mode 100644 index 00000000..51424b68 --- /dev/null +++ b/app/services/map_activity_service.rb @@ -0,0 +1,98 @@ +class MapActivityService + + def self.subject_line(map) + 'Activity on map ' + map.name + end + + def self.summarize_data(map, user, until_moment = DateTime.now) + results = { + stats: {} + } + + since = until_moment - 24.hours + + scoped_topic_ids = TopicPolicy::Scope.new(user, map.topics).resolve.map(&:id) + scoped_synapse_ids = SynapsePolicy::Scope.new(user, map.synapses).resolve.map(&:id) + + message_count = Message.where(resource: map) + .where("created_at > ? AND created_at < ?", since, until_moment) + .where.not(user: user).count + if message_count > 0 + results[:stats][:messages_sent] = message_count + end + + moved_count = Event.where(kind: 'topic_moved_on_map', map: map) + .where("created_at > ? AND created_at < ?", since, until_moment) + .where(eventable_id: scoped_topic_ids) + .where.not(user: user).group(:eventable_id).count + if moved_count.keys.length > 0 + results[:stats][:topics_moved] = moved_count.keys.length + end + + topics_added_events = Event.where(kind: 'topic_added_to_map', map: map) + .where("created_at > ? AND created_at < ?", since, until_moment) + .where.not(user: user) + .order(:created_at) + + topics_removed_events = Event.where(kind: 'topic_removed_from_map', map: map) + .where("created_at > ? AND created_at < ?", since, until_moment) + .where.not(user: user) + .order(:created_at) + + topics_added_to_include = {} + topics_added_events.each do |ta| + num_adds = topics_added_events.where(eventable_id: ta.eventable_id).count + num_removes = topics_removed_events.where(eventable_id: ta.eventable_id).count + topics_added_to_include[ta.eventable_id] = ta if num_adds > num_removes && scoped_topic_ids.include?(ta.eventable.id) + end + if topics_added_to_include.keys.length > 0 + results[:stats][:topics_added] = topics_added_to_include.keys.length + results[:topics_added] = topics_added_to_include.values + end + + topics_removed_to_include = {} + topics_removed_events.each do |ta| + num_adds = topics_added_events.where(eventable_id: ta.eventable_id).count + num_removes = topics_removed_events.where(eventable_id: ta.eventable_id).count + topics_removed_to_include[ta.eventable_id] = ta if num_removes > num_adds && TopicPolicy.new(user, ta.eventable).show? + end + if topics_removed_to_include.keys.length > 0 + results[:stats][:topics_removed] = topics_removed_to_include.keys.length + results[:topics_removed] = topics_removed_to_include.values + end + + synapses_added_events = Event.where(kind: 'synapse_added_to_map', map: map) + .where("created_at > ? AND created_at < ?", since, until_moment) + .where.not(user: user) + .order(:created_at) + + synapses_removed_events = Event.where(kind: 'synapse_removed_from_map', map: map) + .where("created_at > ? AND created_at < ?", since, until_moment) + .where.not(user: user) + .order(:created_at) + + synapses_added_to_include = {} + synapses_added_events.each do |ta| + num_adds = synapses_added_events.where(eventable_id: ta.eventable_id).count + num_removes = synapses_removed_events.where(eventable_id: ta.eventable_id).count + synapses_added_to_include[ta.eventable_id] = ta if num_adds > num_removes && scoped_synapse_ids.include?(ta.eventable.id) + end + if synapses_added_to_include.keys.length > 0 + results[:stats][:synapses_added] = synapses_added_to_include.keys.length + results[:synapses_added] = synapses_added_to_include.values + end + + synapses_removed_to_include = {} + synapses_removed_events.each do |ta| + num_adds = synapses_added_events.where(eventable_id: ta.eventable_id).count + num_removes = synapses_removed_events.where(eventable_id: ta.eventable_id).count + synapses_removed_to_include[ta.eventable_id] = ta if num_removes > num_adds && SynapsePolicy.new(user, ta.eventable).show? + end + if synapses_removed_to_include.keys.length > 0 + results[:stats][:synapses_removed] = synapses_removed_to_include.keys.length + results[:synapses_removed] = synapses_removed_to_include.values + end + + results + end +end diff --git a/app/views/map_activity_mailer/daily_summary.html.erb b/app/views/map_activity_mailer/daily_summary.html.erb new file mode 100644 index 00000000..bd9f79da --- /dev/null +++ b/app/views/map_activity_mailer/daily_summary.html.erb @@ -0,0 +1,57 @@ +<% button_style = "background-color:#4fc059;border-radius:2px;color:white;display:inline-block;font-family:Roboto,Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;min-height:29px;line-height:29px;min-width:54px;outline:0px;padding:0 8px;text-align:center;text-decoration:none" %> + +<!DOCTYPE html> +<div style="padding: 16px; background: white; text-align: left; font-family: Arial"> + <p>Hey <%= @user.name %>, there was activity by others in the last 24 hours on map + <%= link_to @map.name, map_url(@map) %> + </p> + <p># of messages: <%= @summary_data[:stats][:messages_sent] || 0 %></p> + <p># of topics added: <%= @summary_data[:stats][:topics_added] || 0 %></p> + <p># of topics moved: <%= @summary_data[:stats][:topics_moved] || 0%></p> + <p># of topics removed: <%= @summary_data[:stats][:topics_removed] || 0 %></p> + <p># of synapses added: <%= @summary_data[:stats][:synapses_added] || 0 %></p> + <p># of synapses removed: <%= @summary_data[:stats][:synapses_removed] || 0 %></p> + <hr> + <% if @summary_data[:topics_added] %> + <h2>Topics Added</h2> + <ul> + <% @summary_data[:topics_added].each do |event| %> + <li><%= event.eventable.name %></li> + <% end %> + </ul> + <% end %> + + <% if @summary_data[:topics_removed] %> + <h2>Topics Removed</h2> + <ul> + <% @summary_data[:topics_removed].each do |event| %> + <li><%= event.eventable.name %></li> + <% end %> + </ul> + <% end %> + + <% if @summary_data[:synapses_added] %> + <h2>Synapses Added</h2> + <ul> + <% @summary_data[:synapses_added].each do |event| %> + <li><%= event.eventable.topic1.name %> -> <%= event.eventable.topic2.name %></li> + <% end %> + </ul> + <% end %> + + <% if @summary_data[:synapses_removed] %> + <h2>Synapses Removed</h2> + <ul> + <% @summary_data[:synapses_removed].each do |event| %> + <li><%= event.eventable.topic1.name %> -> <%= event.eventable.topic2.name %></li> + <% end %> + </ul> + <% end %> + + <%= link_to 'Visit Map', map_url(@map), style: button_style %> + + <hr> + <p style="font-size: 14px;">Make sense with Metamaps</p> + <%= link_to 'Unfollow this map', unfollow_from_email_map_url(@map) %> + <%= render partial: 'shared/mailer_unsubscribe_link' %> +</div> diff --git a/app/views/shared/_mailer_unsubscribe_link.html.erb b/app/views/shared/_mailer_unsubscribe_link.html.erb index 56730dd9..5aab4689 100644 --- a/app/views/shared/_mailer_unsubscribe_link.html.erb +++ b/app/views/shared/_mailer_unsubscribe_link.html.erb @@ -1,3 +1,3 @@ <div class="unsubscribe-link"> - <%= link_to 'Click here to unsubscribe from all Metamaps emails', unsubscribe_notifications_url(protocol: Rails.env.production? ? :https : :http) %> + <%= link_to 'Unsubscribe from all Metamaps emails', unsubscribe_notifications_url(protocol: Rails.env.production? ? :https : :http) %> </div> diff --git a/config/initializers/mailboxer.rb b/config/initializers/mailboxer.rb index b09caec2..b8abd079 100644 --- a/config/initializers/mailboxer.rb +++ b/config/initializers/mailboxer.rb @@ -15,8 +15,6 @@ MAP_ACCESS_REQUEST = 'ACCESS_REQUEST' MAP_INVITE_TO_EDIT = 'INVITE_TO_EDIT' # these ones are new -# this one's a catch all for occurences on the map -# MAP_ACTIVITY = 'MAP_ACTIVITY' # MAP_RECEIVED_TOPIC # MAP_LOST_TOPIC # MAP_TOPIC_MOVED diff --git a/lib/tasks/emails.rake b/lib/tasks/emails.rake new file mode 100644 index 00000000..b2a7303a --- /dev/null +++ b/lib/tasks/emails.rake @@ -0,0 +1,20 @@ +namespace :metamaps do + desc "delivers recent map activity digest emails to users" + task deliver_map_activity_emails: :environment do + summarize_map_activity + end + + def summarize_map_activity + Follow.where(followed_type: 'Map').find_each do |follow| + map = follow.followed + user = follow.user + # add logging and rescue-ing + # and a notification of failure + next unless MapPolicy.new(user, map).show? # just in case the permission changed + next unless user.emails_allowed + summary_data = MapActivityService.summarize_data(map, user) + next if summary_data[:stats].blank? + MapActivityMailer.daily_summary(user, map, summary_data).deliver_later + end + end +end diff --git a/spec/factories/message.rb b/spec/factories/message.rb new file mode 100644 index 00000000..a0930fec --- /dev/null +++ b/spec/factories/message.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +FactoryGirl.define do + factory :message do + association :resource, factory: :map + user + sequence(:message) { |n| "Cool Message ##{n}" } + end +end diff --git a/spec/mailers/map_activity_mailer_spec.rb b/spec/mailers/map_activity_mailer_spec.rb new file mode 100644 index 00000000..897a38c0 --- /dev/null +++ b/spec/mailers/map_activity_mailer_spec.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe MapActivityMailer, type: :mailer do + +end diff --git a/spec/mailers/previews/map_activity_mailer_preview.rb b/spec/mailers/previews/map_activity_mailer_preview.rb new file mode 100644 index 00000000..3d943eee --- /dev/null +++ b/spec/mailers/previews/map_activity_mailer_preview.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true +# Preview all emails at http://localhost:3000/rails/mailers/map_activity_mailer +class MapActivityMailerPreview < ActionMailer::Preview + def daily_summary + user = generate_user + map = generate_map + generate_recent_activity_on_map(map) + summary_data = MapActivityService.summarize_data(map, user) + MapActivityMailer.daily_summary(user, map, summary_data) + end + + private + def generate_recent_activity_on_map(map) + mapping = nil + mapping2 = nil + mapping3 = nil + mapping4 = nil + mapping5 = nil + mapping6 = nil + mapping7 = nil + mapping8 = nil + mapping9 = nil + mapping10 = nil + + Timecop.freeze(2.days.ago) do + mapping = topic_added_to_map(map) + mapping2 = topic_added_to_map(map) + mapping3 = topic_added_to_map(map) + mapping4 = topic_added_to_map(map) + mapping5 = topic_added_to_map(map) + mapping6 = topic_added_to_map(map) + mapping7 = topic_added_to_map(map) + mapping8 = topic_added_to_map(map) + mapping9 = synapse_added_to_map(map, mapping.mappable, mapping2.mappable) + mapping10 = synapse_added_to_map(map, mapping.mappable, mapping8.mappable) + end + Timecop.return + + Timecop.freeze(2.hours.ago) do + topic_moved_on_map(mapping7) + topic_moved_on_map(mapping8) + generate_message(map) + generate_message(map) + generate_message(map) + synapse_added_to_map(map, mapping7.mappable, mapping8.mappable) + synapse_added_to_map(map, mapping.mappable, mapping8.mappable) + synapse_removed_from_map(mapping9) + synapse_removed_from_map(mapping10) + end + Timecop.return + + Timecop.freeze(30.minutes.ago) do + topic_removed_from_map(mapping3) + topic_removed_from_map(mapping4) + topic_removed_from_map(mapping5) + topic_removed_from_map(mapping6) + topic_added_to_map(map) + topic_added_to_map(map) + topic_added_to_map(map) + topic_added_to_map(map) + topic_added_to_map(map) + topic_added_to_map(map) + topic_added_to_map(map) + topic_added_to_map(map) + end + Timecop.return + end + + def generate_user + User.create(name: Faker::Name.name, email: Faker::Internet.email, password: "password", password_confirmation: "password", joinedwithcode: 'qwertyui') + end + + def generate_map + Map.create(name: Faker::HarryPotter.book, permission: 'commons', arranged: false, user: generate_user) + end + + def topic_added_to_map(map) + user = generate_user + topic = Topic.create(name: Faker::Friends.quote, permission: 'commons', user: user) + mapping = Mapping.create(map: map, mappable: topic, user: user) + end + + def topic_moved_on_map(mapping) + meta = { 'x': 10, 'y': 20, 'mapping_id': mapping.id } + Events::TopicMovedOnMap.publish!(mapping.mappable, mapping.map, generate_user, meta) + end + + def topic_removed_from_map(mapping) + user = generate_user + mapping.updated_by = user + mapping.destroy + end + + def synapse_added_to_map(map, topic1, topic2) + user = generate_user + topic = Synapse.create(desc: 'describes', permission: 'commons', user: user, topic1: topic1, topic2: topic2) + mapping = Mapping.create(map: map, mappable: topic, user: user) + end + + def synapse_removed_from_map(mapping) + user = generate_user + mapping.updated_by = user + mapping.destroy + end + + def generate_message(map) + Message.create(message: Faker::HarryPotter.quote, resource: map, user: generate_user) + end +end diff --git a/spec/services/map_activity_service_spec.rb b/spec/services/map_activity_service_spec.rb new file mode 100644 index 00000000..fe283e01 --- /dev/null +++ b/spec/services/map_activity_service_spec.rb @@ -0,0 +1,398 @@ +require 'rails_helper' + +RSpec.describe MapActivityService do + let(:map) { create(:map, created_at: 1.week.ago) } + let(:other_user) { create(:user) } + let(:email_user) { create(:user) } + let(:empty_response) { {stats:{}} } + + it 'includes nothing if nothing happened' do + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + describe 'topics added to map' do + it 'includes a topic added within the last 24 hours' do + topic = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 6.hours.ago) + event = Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id) + event.update_columns(created_at: 6.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:topics_added]).to eq(1) + expect(response[:topics_added]).to eq([event]) + end + + it 'includes a topic added, then removed, then re-added within the last 24 hours' do + topic = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 6.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 6.hours.ago) + mapping.updated_by = other_user + mapping.destroy + Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id).update_columns(created_at: 5.hours.ago) + mapping2 = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 4.hours.ago) + event = Event.where("meta->>'mapping_id' = ?", mapping2.id.to_s).first + event.update_columns(created_at: 4.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:topics_added]).to eq(1) + expect(response[:topics_added]).to eq([event]) + end + + it 'excludes a topic removed then re-added within the last 24 hours' do + topic = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago) + mapping.updated_by = other_user + mapping.destroy + Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id).update_columns(created_at: 6.hours.ago) + mapping2 = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 5.hours.ago) + Event.where(kind: 'topic_added_to_map').where("meta->>'mapping_id' = ?", mapping2.id.to_s).first.update_columns(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + it 'excludes a topic added outside the last 24 hours' do + topic = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + it 'excludes topics added by the user who will receive the data' do + topic = create(:topic) + topic2 = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 5.hours.ago) + event = Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id) + event.update_columns(created_at: 5.hours.ago) + mapping2 = create(:mapping, user: email_user, map: map, mappable: topic2, created_at: 5.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic2.id).update_columns(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:topics_added]).to eq(1) + expect(response[:topics_added]).to eq([event]) + end + end + + describe 'topics moved on map' do + it 'includes ones moved within the last 24 hours' do + topic = create(:topic) + create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago) + event = Events::TopicMovedOnMap.publish!(topic, map, other_user, {}) + event.update(created_at: 6.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:topics_moved]).to eq(1) + end + + it 'only includes each topic that was moved in the count once' do + topic = create(:topic) + topic2 = create(:topic) + create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago) + create(:mapping, user: email_user, map: map, mappable: topic2, created_at: 5.hours.ago) + event = Events::TopicMovedOnMap.publish!(topic, map, other_user, {}) + event.update(created_at: 6.hours.ago) + event2 = Events::TopicMovedOnMap.publish!(topic, map, other_user, {}) + event2.update(created_at: 5.hours.ago) + event3 = Events::TopicMovedOnMap.publish!(topic2, map, other_user, {}) + event3.update(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:topics_moved]).to eq(2) + end + + it 'excludes ones moved outside the last 24 hours' do + topic = create(:topic) + create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago) + event = Events::TopicMovedOnMap.publish!(topic, map, other_user, {}) + event.update(created_at: 25.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + it 'excludes ones moved by the user who will receive the data' do + topic = create(:topic) + create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago) + event = Events::TopicMovedOnMap.publish!(topic, map, email_user, {}) + event.update(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + end + + describe 'topics removed from map' do + it 'includes a topic removed within the last 24 hours' do + topic = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago) + mapping.updated_by = other_user + mapping.destroy + event = Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id) + event.update_columns(created_at: 6.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:topics_removed]).to eq(1) + expect(response[:topics_removed]).to eq([event]) + end + + it 'excludes a topic removed outside the last 24 hours' do + topic = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 26.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 26.hours.ago) + mapping.updated_by = other_user + mapping.destroy + Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + it 'excludes topics removed by the user who will receive the data' do + topic = create(:topic) + topic2 = create(:topic) + mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago) + mapping2 = create(:mapping, user: email_user, map: map, mappable: topic2, created_at: 25.hours.ago) + Event.find_by(kind: 'topic_added_to_map', eventable_id: topic2.id).update_columns(created_at: 25.hours.ago) + mapping.updated_by = other_user + mapping.destroy + mapping2.updated_by = email_user + mapping2.destroy + event = Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id) + event.update_columns(created_at: 5.hours.ago) + Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic2.id).update_columns(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:topics_removed]).to eq(1) + expect(response[:topics_removed]).to eq([event]) + end + end + + describe 'synapses added to map' do + it 'includes a synapse added within the last 24 hours' do + synapse = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 6.hours.ago) + event = Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id) + event.update_columns(created_at: 6.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:synapses_added]).to eq(1) + expect(response[:synapses_added]).to eq([event]) + end + + it 'includes a synapse added, then removed, then re-added within the last 24 hours' do + synapse = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 6.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 6.hours.ago) + mapping.updated_by = other_user + mapping.destroy + Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id).update_columns(created_at: 5.hours.ago) + mapping2 = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 4.hours.ago) + event = Event.where(kind: 'synapse_added_to_map').where("meta->>'mapping_id' = ?", mapping2.id.to_s).first + event.update_columns(created_at: 4.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:synapses_added]).to eq(1) + expect(response[:synapses_added]).to eq([event]) + end + + it 'excludes a synapse removed then re-added within the last 24 hours' do + synapse = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago) + mapping.updated_by = other_user + mapping.destroy + Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id).update_columns(created_at: 6.hours.ago) + mapping2 = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 5.hours.ago) + Event.where(kind: 'synapse_added_to_map').where("meta->>'mapping_id' = ?", mapping2.id.to_s).first.update_columns(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + it 'excludes a synapse added outside the last 24 hours' do + synapse = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + it 'excludes synapses added by the user who will receive the data' do + synapse = create(:synapse) + synapse2 = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 5.hours.ago) + event = Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id) + event.update_columns(created_at: 5.hours.ago) + mapping2 = create(:mapping, user: email_user, map: map, mappable: synapse2, created_at: 5.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse2.id).update_columns(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:synapses_added]).to eq(1) + expect(response[:synapses_added]).to eq([event]) + end + end + + describe 'synapses removed from map' do + it 'includes a synapse removed within the last 24 hours' do + synapse = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago) + mapping.updated_by = other_user + mapping.destroy + event = Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id) + event.update_columns(created_at: 6.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:synapses_removed]).to eq(1) + expect(response[:synapses_removed]).to eq([event]) + end + + it 'excludes a synapse removed outside the last 24 hours' do + synapse = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago) + mapping.updated_by = other_user + mapping.destroy + Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response).to eq (empty_response) + end + + it 'excludes synapses removed by the user who will receive the data' do + synapse = create(:synapse) + synapse2 = create(:synapse) + mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago) + mapping2 = create(:mapping, user: email_user, map: map, mappable: synapse2, created_at: 25.hours.ago) + Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse2.id).update_columns(created_at: 25.hours.ago) + mapping.updated_by = other_user + mapping.destroy + mapping2.updated_by = email_user + mapping2.destroy + event = Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id) + event.update_columns(created_at: 5.hours.ago) + Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse2.id).update_columns(created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:synapses_removed]).to eq(1) + expect(response[:synapses_removed]).to eq([event]) + end + end + + it 'handles permissions for topics added' do + new_topic = nil + new_private_topic = nil + + Timecop.freeze(10.hours.ago) do + new_topic = create(:topic, permission: 'commons', user: other_user) + create(:mapping, map: map, mappable: new_topic, user: other_user) + new_private_topic = create(:topic, permission: 'private', user: other_user) + create(:mapping, map: map, mappable: new_private_topic, user: other_user) + end + Timecop.return + + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats]).to eq({ + topics_added: 1 + }) + expect(response[:topics_added].map(&:eventable_id)).to include(new_topic.id) + expect(response[:topics_added].map(&:eventable_id)).to_not include(new_private_topic.id) + end + + it 'handles permissions for topics removed' do + old_topic = nil + old_private_topic = nil + old_topic_mapping = nil + old_private_topic_mapping = nil + + Timecop.freeze(2.days.ago) do + old_topic = create(:topic, permission: 'commons', user: other_user) + old_topic_mapping = create(:mapping, map: map, mappable: old_topic, user: other_user) + old_private_topic = create(:topic, permission: 'private', user: other_user) + old_private_topic_mapping = create(:mapping, map: map, mappable: old_private_topic, user: other_user) + end + Timecop.return + + Timecop.freeze(10.hours.ago) do + # visible + old_topic_mapping.updated_by = other_user + old_topic_mapping.destroy + # not visible + old_private_topic_mapping.updated_by = other_user + old_private_topic_mapping.destroy + end + Timecop.return + + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats]).to eq({ + topics_removed: 1 + }) + expect(response[:topics_removed].map(&:eventable_id)).to include(old_topic.id) + expect(response[:topics_removed].map(&:eventable_id)).to_not include(old_private_topic.id) + end + + it 'handles permissions for synapses added' do + new_synapse = nil + new_private_synapse = nil + + Timecop.freeze(10.hours.ago) do + # visible + new_synapse = create(:synapse, permission: 'commons', user: other_user) + create(:mapping, map: map, mappable: new_synapse, user: other_user) + # not visible + new_private_synapse = create(:synapse, permission: 'private', user: other_user) + create(:mapping, map: map, mappable: new_private_synapse, user: other_user) + end + Timecop.return + + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats]).to eq({ + synapses_added: 1 + }) + expect(response[:synapses_added].map(&:eventable_id)).to include(new_synapse.id) + expect(response[:synapses_added].map(&:eventable_id)).to_not include(new_private_synapse.id) + end + + it 'handles permissions for synapses removed' do + old_synapse = nil + old_private_synapse = nil + old_synapse_mapping = nil + old_private_synapse_mapping = nil + + Timecop.freeze(2.days.ago) do + old_synapse = create(:synapse, permission: 'commons', user: other_user) + old_synapse_mapping = create(:mapping, map: map, mappable: old_synapse, user: other_user) + old_private_synapse = create(:synapse, permission: 'private', user: other_user) + old_private_synapse_mapping = create(:mapping, map: map, mappable: old_private_synapse, user: other_user) + end + Timecop.return + + Timecop.freeze(10.hours.ago) do + # visible + old_synapse_mapping.updated_by = other_user + old_synapse_mapping.destroy + # not visible + old_private_synapse_mapping.updated_by = other_user + old_private_synapse_mapping.destroy + end + Timecop.return + + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats]).to eq({ + synapses_removed: 1 + }) + expect(response[:synapses_removed].map(&:eventable_id)).to include(old_synapse.id) + expect(response[:synapses_removed].map(&:eventable_id)).to_not include(old_private_synapse.id) + end + + describe 'messages in the map chat' do + it 'counts messages within the last 24 hours' do + create(:message, resource: map, created_at: 6.hours.ago) + create(:message, resource: map, created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:messages_sent]).to eq(2) + end + + it 'does not count messages outside the last 24 hours' do + create(:message, resource: map, created_at: 25.hours.ago) + create(:message, resource: map, created_at: 5.hours.ago) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:messages_sent]).to eq(1) + end + + it 'does not count messages sent by the person who will receive the data' do + create(:message, resource: map, created_at: 5.hours.ago, user: other_user) + create(:message, resource: map, created_at: 5.hours.ago, user: email_user) + response = MapActivityService.summarize_data(map, email_user) + expect(response[:stats][:messages_sent]).to eq(1) + end + end +end From ce51eeca8c64a3a61f19f310b05a21cab59c9b92 Mon Sep 17 00:00:00 2001 From: Devin Howard <devin@callysto.com> Date: Tue, 7 Mar 2017 07:54:25 -0800 Subject: [PATCH 22/32] remove dedupe plugin --- webpack.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index a207d23c..d55d151f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,7 +12,6 @@ const plugins = [ const externals = ["bindings"] // work around bindings.js error if (NODE_ENV === 'production') { - plugins.push(new webpack.optimize.DedupePlugin()) plugins.push(new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } })) From 8998e3858ccdb983a4a9c3842e0a7863744c7e36 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Tue, 7 Mar 2017 16:55:15 +0000 Subject: [PATCH 23/32] fixes bug where popups are happening --- frontend/src/Metamaps/GlobalUI/ImportDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Metamaps/GlobalUI/ImportDialog.js b/frontend/src/Metamaps/GlobalUI/ImportDialog.js index 31913ea6..30215fa1 100644 --- a/frontend/src/Metamaps/GlobalUI/ImportDialog.js +++ b/frontend/src/Metamaps/GlobalUI/ImportDialog.js @@ -27,7 +27,7 @@ const ImportDialog = { onFileAdded: PasteInput.handleFile, exampleImageUrl: serverData['import-example.png'], downloadScreenshot: ImportDialog.downloadScreenshot, - onExport: format => { + onExport: format => () => { window.open(`${window.location.pathname}/export.${format}`, '_blank') } }), $('.importDialogWrapper').get(0)) From e3b4dac1e15bcf5d97fb20eaf546dd1ca39ab7bf Mon Sep 17 00:00:00 2001 From: Devin Howard <devin@callysto.com> Date: Tue, 7 Mar 2017 09:15:28 -0800 Subject: [PATCH 24/32] remove takeScreenshot button in favour of separate buttons in the map card and import/export dialogue (#1086) --- app/assets/stylesheets/clean.css.erb | 17 ++--------------- app/views/layouts/_lowermapelements.html.erb | 1 - frontend/src/Metamaps/JIT.js | 2 -- frontend/src/Metamaps/Map/index.js | 5 ----- 4 files changed, 2 insertions(+), 23 deletions(-) diff --git a/app/assets/stylesheets/clean.css.erb b/app/assets/stylesheets/clean.css.erb index 3970f877..1582404e 100644 --- a/app/assets/stylesheets/clean.css.erb +++ b/app/assets/stylesheets/clean.css.erb @@ -455,19 +455,6 @@ z-index: 4; } -.takeScreenshot { - margin-bottom: 5px; - border-radius: 2px; - background-image: url(<%= asset_path 'screenshot_sprite.png' %>); - display: none; -} -.takeScreenshot:hover { - background-position: -32px 0; -} -.canEditMap .takeScreenshot { - display: block; -} - .zoomExtents { margin-bottom:5px; border-radius: 2px; @@ -478,7 +465,7 @@ background-position: -32px 0; } -.zoomExtents:hover .tooltips, .zoomIn:hover .tooltips, .zoomOut:hover .tooltips, .takeScreenshot:hover .tooltips, .sidebarFilterIcon:hover .tooltipsUnder, .sidebarForkIcon:hover .tooltipsUnder, .notificationsIcon:hover .tooltipsUnder, .addMap:hover .tooltipsUnder, .authenticated .sidebarAccountIcon:hover .tooltipsUnder, +.zoomExtents:hover .tooltips, .zoomIn:hover .tooltips, .zoomOut:hover .tooltips, .sidebarFilterIcon:hover .tooltipsUnder, .sidebarForkIcon:hover .tooltipsUnder, .notificationsIcon:hover .tooltipsUnder, .addMap:hover .tooltipsUnder, .authenticated .sidebarAccountIcon:hover .tooltipsUnder, .mapInfoIcon:hover .tooltipsAbove, .openCheatsheet:hover .tooltipsAbove, .chat-button:hover .tooltips, .importDialog:hover .tooltipsUnder, .starMap:hover .tooltipsAbove, .openMetacodeSwitcher:hover .tooltipsAbove, .pinCarousel:not(.isPinned):hover .tooltipsAbove.helpPin, .pinCarousel.isPinned:hover .tooltipsAbove.helpUnpin { display: block; } @@ -609,7 +596,7 @@ margin-top: 40px; } -.zoomExtents div::after, .zoomIn div::after, .zoomOut div::after, .takeScreenshot div:after, .chat-button div.tooltips::after { +.zoomExtents div::after, .zoomIn div::after, .zoomOut div::after, .chat-button div.tooltips::after { content: ''; position: absolute; top: 57%; diff --git a/app/views/layouts/_lowermapelements.html.erb b/app/views/layouts/_lowermapelements.html.erb index 82ec71f2..e3d5aeaf 100644 --- a/app/views/layouts/_lowermapelements.html.erb +++ b/app/views/layouts/_lowermapelements.html.erb @@ -1,5 +1,4 @@ <div class="mapControls mapElement"> - <div class="takeScreenshot mapControl"><div class="tooltips">Capture Screenshot</div></div> <div class="zoomExtents mapControl"><div class="tooltips">Center View</div></div> <div class="zoomIn mapControl"><div class="tooltips">Zoom In</div></div> <div class="zoomOut mapControl"><div class="tooltips">Zoom Out</div></div> diff --git a/frontend/src/Metamaps/JIT.js b/frontend/src/Metamaps/JIT.js index bd952c21..1d5ff53a 100644 --- a/frontend/src/Metamaps/JIT.js +++ b/frontend/src/Metamaps/JIT.js @@ -59,8 +59,6 @@ const JIT = { } $('.zoomExtents').click(zoomExtents) - $('.takeScreenshot').click(Map.exportImage) - self.topicDescImage = new Image() self.topicDescImage.src = serverData['topic_description_signifier.png'] diff --git a/frontend/src/Metamaps/Map/index.js b/frontend/src/Metamaps/Map/index.js index 0b36a68d..e3c3bbc6 100644 --- a/frontend/src/Metamaps/Map/index.js +++ b/frontend/src/Metamaps/Map/index.js @@ -253,11 +253,6 @@ const Map = { DataModel.Mappers.add(Active.Mapper) } }, - exportImage: function() { - Map.uploadMapScreenshot() - Map.offerScreenshotDownload() - GlobalUI.notifyUser('Note: this button is going away. Check the map card or the import box for setting the map thumbnail or downloading a screenshot.') - }, offerScreenshotDownload: () => { const canvas = Map.getMapCanvasForScreenshots() const filename = Map.getMapScreenshotFilename(Active.Map) From 8483b6260343a7ffeda46525c3c5127f7cc9c7f5 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Wed, 8 Mar 2017 18:50:39 +0000 Subject: [PATCH 25/32] allow users to select follow settings --- app/controllers/users_controller.rb | 15 ++++++++++--- app/models/map.rb | 5 ++--- app/models/user.rb | 10 ++++++++- app/models/user_preference.rb | 11 +++++++++- app/services/follow_service.rb | 18 ++++++++++++++++ app/views/layouts/_account.html.erb | 2 +- app/views/layouts/_foot.html.erb | 2 +- app/views/users/edit.html.erb | 20 ++++++++++++++++- frontend/src/Metamaps/Control.js | 24 +++++++++++++++++---- frontend/src/Metamaps/DataModel/Mapper.js | 6 ++++-- frontend/src/Metamaps/GlobalUI/CreateMap.js | 3 +++ frontend/src/Metamaps/JIT.js | 3 +++ frontend/src/Metamaps/Synapse.js | 10 +++++++++ frontend/src/Metamaps/Topic.js | 6 ++++++ 14 files changed, 118 insertions(+), 17 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7aff655d..9bf10ac7 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -23,11 +23,12 @@ class UsersController < ApplicationController if user_params[:password] == '' && user_params[:password_confirmation] == '' # not trying to change the password if @user.update_attributes(user_params.except(:password, :password_confirmation)) + update_follow_settings(@user, params[:settings]) @user.image = nil if params[:remove_image] == '1' @user.save sign_in(@user, bypass: true) respond_to do |format| - format.html { redirect_to root_url, notice: 'Account updated!' } + format.html { redirect_to root_url, notice: 'Settings updated' } end else sign_in(@user, bypass: true) @@ -40,11 +41,12 @@ class UsersController < ApplicationController correct_pass = @user.valid_password?(params[:current_password]) if correct_pass && @user.update_attributes(user_params) + update_follow_settings(@user, params[:settings]) @user.image = nil if params[:remove_image] == '1' @user.save sign_in(@user, bypass: true) respond_to do |format| - format.html { redirect_to root_url, notice: 'Account updated!' } + format.html { redirect_to root_url, notice: 'Settings updated' } end else respond_to do |format| @@ -104,9 +106,16 @@ class UsersController < ApplicationController private + def update_follow_settings(user, settings) + user.settings.follow_topic_on_created = settings[:follow_topic_on_created] + user.settings.follow_topic_on_contributed = settings[:follow_topic_on_contributed] + user.settings.follow_map_on_created = settings[:follow_map_on_created] + user.settings.follow_map_on_contributed = settings[:follow_map_on_contributed] + end + def user_params params.require(:user).permit( - :name, :email, :image, :password, :password_confirmation, :emails_allowed + :name, :email, :image, :password, :password_confirmation, :emails_allowed, :settings ) end end diff --git a/app/models/map.rb b/app/models/map.rb index a149a760..dd5e5604 100644 --- a/app/models/map.rb +++ b/app/models/map.rb @@ -39,7 +39,7 @@ class Map < ApplicationRecord # Validate the attached image is image/jpg, image/png, etc validates_attachment_content_type :screenshot, content_type: %r{\Aimage/.*\Z} - after_create :after_created_async + after_create :after_created after_update :after_updated after_save :update_deferring_topics_and_synapses, if: :permission_changed? @@ -140,11 +140,10 @@ class Map < ApplicationRecord protected - def after_created_async + def after_created FollowService.follow(self, self.user, 'created') # notify users following the map creator end - handle_asynchronously :after_created_async def after_updated return unless ATTRS_TO_WATCH.any? { |k| changed_attributes.key?(k) } diff --git a/app/models/user.rb b/app/models/user.rb index 33ddc8d3..2c7ddf13 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -62,6 +62,12 @@ class User < ApplicationRecord maps: following.where(followed_type: 'Map').to_a.map(&:followed_id) } end + if (_options[:follow_settings]) + json['follow_topic_on_created'] = settings.follow_topic_on_created == "1" + json['follow_topic_on_contributed'] = settings.follow_topic_on_contributed == "1" + json['follow_map_on_created'] = settings.follow_map_on_created == "1" + json['follow_map_on_contributed'] = settings.follow_map_on_contributed == "1" + end if (_options[:email]) json['email'] = email end @@ -127,8 +133,10 @@ class User < ApplicationRecord end def settings - # make sure we always return a UserPreference instance self[:settings] = UserPreference.new if self[:settings].nil? + if not self[:settings].respond_to?(:follow_topic_on_created) + self[:settings].initialize_follow_settings + end self[:settings] end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 9ea37532..c881c4ac 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class UserPreference - attr_accessor :metacodes, :metacode_focus + attr_accessor :metacodes, :metacode_focus, :follow_topic_on_created, :follow_topic_on_contributed, + :follow_map_on_created, :follow_map_on_contributed def initialize array = [] @@ -16,5 +17,13 @@ class UserPreference end @metacodes = array @metacode_focus = array[0] + initialize_follow_settings + end + + def initialize_follow_settings + @follow_topic_on_created = false + @follow_topic_on_contributed = false + @follow_map_on_created = false + @follow_map_on_contributed = false end end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 53add1cf..83d86bcd 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -5,6 +5,8 @@ class FollowService return unless is_tester(user) + return unless should_auto_follow(entity, user, reason) + follow = Follow.where(followed: entity, user: user).first_or_create if FollowReason::REASONS.include?(reason) && !follow.follow_reason.read_attribute(reason) follow.follow_reason.update_attribute(reason, true) @@ -28,6 +30,22 @@ class FollowService protected + def should_auto_follow(entity, user, reason) + if entity.class == Topic + if reason == 'created' + return user.settings.follow_topic_on_created == '1' + elsif reason == 'contributed' + return user.settings.follow_topic_on_contributed == '1' + end + elsif entity.class == Map + if reason == 'created' + return user.settings.follow_map_on_created == '1' + elsif reason == 'contributed' + return user.settings.follow_map_contributed == '1' + end + end + end + def is_tester(user) %w(connorturland@gmail.com devin@callysto.com chessscholar@gmail.com solaureum@gmail.com ishanshapiro@gmail.com).include?(user.email) end diff --git a/app/views/layouts/_account.html.erb b/app/views/layouts/_account.html.erb index 3d66f687..94f69f62 100644 --- a/app/views/layouts/_account.html.erb +++ b/app/views/layouts/_account.html.erb @@ -10,7 +10,7 @@ <ul> <li class="accountListItem accountSettings"> <div class="accountIcon"></div> - <%= link_to "Account", edit_user_url(account) %> + <%= link_to "Settings", edit_user_url(account) %> </li> <% if account.admin %> <li class="accountListItem accountAdmin"> diff --git a/app/views/layouts/_foot.html.erb b/app/views/layouts/_foot.html.erb index 0912b062..dfe319bb 100644 --- a/app/views/layouts/_foot.html.erb +++ b/app/views/layouts/_foot.html.erb @@ -3,7 +3,7 @@ <%= render :partial => 'shared/metacodeBgColors' %> <script type="text/javascript" charset="utf-8"> <% if current_user %> - Metamaps.ServerData.ActiveMapper = <%= current_user.to_json({follows: true, email: true}).html_safe %> + Metamaps.ServerData.ActiveMapper = <%= current_user.to_json({follows: true, email: true, follow_settings: true}).html_safe %> <% else %> Metamaps.ServerData.ActiveMapper = null <% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 8427582a..1ee406e7 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -8,7 +8,7 @@ <% content_for :mobile_title, "Account Settings" %> <div id="yield"> <%= form_for @user, url: user_url, :html =>{ :multipart => true, :class => "edit_user centerGreyForm"} do |form| %> - <h3>Edit Account</h3> + <h3>Edit Settings</h3> <div class="userImage"> <div class="userImageDiv" onclick="Metamaps.Account.toggleChangePicture()"> <%= image_tag @user.image.url(:ninetysix), :size => "84x84" %> @@ -45,6 +45,24 @@ <%= form.check_box :emails_allowed, class: 'inline' %> Send Metamaps notifications to my email. <% end %> + <%= fields_for :settings, @user.settings do |settings| %> + <%= settings.label :follow_topic_on_created, class: 'firstFieldText' do %> + <%= settings.check_box :follow_topic_on_created, class: 'inline' %> + Auto-follow topics you create. + <% end %> + <%= settings.label :follow_topic_on_contributed, class: 'firstFieldText' do %> + <%= settings.check_box :follow_topic_on_contributed, class: 'inline' %> + Auto-follow topics you edit. + <% end %> + <%= settings.label :follow_map_on_created, class: 'firstFieldText' do %> + <%= settings.check_box :follow_map_on_created, class: 'inline' %> + Auto-follow maps you create. + <% end %> + <%= settings.label :follow_map_on_contributed, class: 'firstFieldText' do %> + <%= settings.check_box :follow_map_on_contributed, class: 'inline' %> + Auto-follow maps you edit. + <% end %> + <% end %> </div> <div class="changePass" onclick="Metamaps.Account.showPass()">Change Password</div> <div class="toHide"> diff --git a/frontend/src/Metamaps/Control.js b/frontend/src/Metamaps/Control.js index 4699a6d5..3bd17e52 100644 --- a/frontend/src/Metamaps/Control.js +++ b/frontend/src/Metamaps/Control.js @@ -123,10 +123,14 @@ const Control = { const authorized = Active.Map.authorizeToEdit(Active.Mapper) if (!authorized) { - GlobalUI.notifyUser('Cannot edit Public map.') + GlobalUI.notifyUser('Cannot edit this map.') return } + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } + for (let i = l - 1; i >= 0; i -= 1) { const node = Selected.Nodes[i] Control.removeNode(node.id) @@ -139,10 +143,14 @@ const Control = { var node = Visualize.mGraph.graph.getNode(nodeid) if (!authorized) { - GlobalUI.notifyUser('Cannot edit Public map.') + GlobalUI.notifyUser('Cannot edit this map.') return } + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } + var topic = node.getData('topic') var mapping = node.getData('mapping') mapping.destroy() @@ -284,10 +292,14 @@ const Control = { var authorized = Active.Map.authorizeToEdit(Active.Mapper) if (!authorized) { - GlobalUI.notifyUser('Cannot edit Public map.') + GlobalUI.notifyUser('Cannot edit this map.') return } + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } + for (let i = l - 1; i >= 0; i -= 1) { const edge = Selected.Edges[i] Control.removeEdge(edge) @@ -300,10 +312,14 @@ const Control = { var authorized = Active.Map.authorizeToEdit(Active.Mapper) if (!authorized) { - GlobalUI.notifyUser('Cannot edit Public map.') + GlobalUI.notifyUser('Cannot edit this map.') return } + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } + if (edge.getData('mappings').length - 1 === 0) { Control.hideEdge(edge) } diff --git a/frontend/src/Metamaps/DataModel/Mapper.js b/frontend/src/Metamaps/DataModel/Mapper.js index dc5e5f0b..20eb5e5a 100644 --- a/frontend/src/Metamaps/DataModel/Mapper.js +++ b/frontend/src/Metamaps/DataModel/Mapper.js @@ -17,14 +17,16 @@ const Mapper = Backbone.Model.extend({ </li>` }, followMap: function(id) { - this.get('follows').maps.push(id) + const idIndex = this.get('follows').maps.indexOf(id) + if (idIndex < 0) this.get('follows').maps.push(id) }, unfollowMap: function(id) { const idIndex = this.get('follows').maps.indexOf(id) if (idIndex > -1) this.get('follows').maps.splice(idIndex, 1) }, followTopic: function(id) { - this.get('follows').topics.push(id) + const idIndex = this.get('follows').topics.indexOf(id) + if (idIndex < 0) this.get('follows').topics.push(id) }, unfollowTopic: function(id) { const idIndex = this.get('follows').topics.indexOf(id) diff --git a/frontend/src/Metamaps/GlobalUI/CreateMap.js b/frontend/src/Metamaps/GlobalUI/CreateMap.js index e7db6219..a92e1836 100644 --- a/frontend/src/Metamaps/GlobalUI/CreateMap.js +++ b/frontend/src/Metamaps/GlobalUI/CreateMap.js @@ -99,6 +99,9 @@ const CreateMap = { success: function(model) { // push the new map onto the collection of 'my maps' DataModel.Maps.Mine.add(model) + if (Active.Mapper.get('follow_map_on_created')) { + Active.Mapper.followMap(model.id) + } GlobalUI.clearNotify() $('#wrapper').append(outdent` diff --git a/frontend/src/Metamaps/JIT.js b/frontend/src/Metamaps/JIT.js index 1d5ff53a..eb023fe8 100644 --- a/frontend/src/Metamaps/JIT.js +++ b/frontend/src/Metamaps/JIT.js @@ -977,6 +977,9 @@ const JIT = { } if (checkWhetherToSave()) { + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } mapping = node.getData('mapping') mapping.save({ xloc: node.getPos().x, diff --git a/frontend/src/Metamaps/Synapse.js b/frontend/src/Metamaps/Synapse.js index 48791637..3d6c5f9a 100644 --- a/frontend/src/Metamaps/Synapse.js +++ b/frontend/src/Metamaps/Synapse.js @@ -42,6 +42,11 @@ const Synapse = { var synapseSuccessCallback = function(synapseModel, response) { if (Active.Map) { mapping.save({ mappable_id: synapseModel.id }, { + success: function(model, response) { + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } + }, error: function(model, response) { console.log('error saving mapping to database') } @@ -59,6 +64,11 @@ const Synapse = { }) } else if (!synapse.isNew() && Active.Map) { mapping.save(null, { + success: function(model, response) { + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } + }, error: function(model, response) { console.log('error saving mapping to database') } diff --git a/frontend/src/Metamaps/Topic.js b/frontend/src/Metamaps/Topic.js index b16da5da..663b0c11 100644 --- a/frontend/src/Metamaps/Topic.js +++ b/frontend/src/Metamaps/Topic.js @@ -242,12 +242,18 @@ const Topic = { } var mappingSuccessCallback = function(mappingModel, response, topicModel) { + if (Active.Mapper.get('follow_map_on_contributed')) { + Active.Mapper.followMap(Active.Map.id) + } // call a success callback if provided if (opts.success) { opts.success(topicModel) } } var topicSuccessCallback = function(topicModel, response) { + if (Active.Mapper.get('follow_topic_on_created')) { + Active.Mapper.followTopic(topicModel.id) + } if (Active.Map) { mapping.save({ mappable_id: topicModel.id }, { success: function(model, response) { From 962881a35d4f91b095f46a1e78a84db1ca78178e Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Wed, 8 Mar 2017 14:13:47 -0500 Subject: [PATCH 26/32] Update follow_service.rb --- app/services/follow_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 83d86bcd..6f5ce6f3 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -3,7 +3,7 @@ class FollowService class << self def follow(entity, user, reason) - return unless is_tester(user) + return unless user && is_tester(user) return unless should_auto_follow(entity, user, reason) From 9079d1bffc79ff1527871ec4eb086da6238d11aa Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Wed, 8 Mar 2017 15:01:58 -0500 Subject: [PATCH 27/32] check permissions prior to sending --- app/services/notification_service.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 177489e1..737dbb4d 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -34,6 +34,11 @@ class NotificationService # we'll prbly want to put the body into the actual loop so we can pass the current user in as a local body = renderer.render(template: settings[:template], locals: { entity: entity, event: event }, layout: false) follows.each{|follow| + if entity.class == Map + next unless MapPolicy.new(follow.user, entity).show? + elsif entity.class == Topic + next unless TopicPolicy.new(follow.user, entity).show? + end # this handles email and in-app notifications, in the future, include push follow.user.notify(settings[:subject], body, event, false, event_type, follow.user.emails_allowed, event.user) # push could be handled with Actioncable to send transient notifications to the UI From 5d0da4c5f1377f4c07523c0ce877ea1be8744209 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Wed, 8 Mar 2017 15:02:22 -0500 Subject: [PATCH 28/32] method was preventing certain follows from succeeding --- app/services/follow_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 6f5ce6f3..e0d23002 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -5,7 +5,7 @@ class FollowService return unless user && is_tester(user) - return unless should_auto_follow(entity, user, reason) + return if (reason == 'created' || reason == 'contributed') && !should_auto_follow(entity, user, reason) follow = Follow.where(followed: entity, user: user).first_or_create if FollowReason::REASONS.include?(reason) && !follow.follow_reason.read_attribute(reason) From de4f51bb5c9948f4a6dd7990b2550b6c13368442 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Wed, 8 Mar 2017 15:45:40 -0500 Subject: [PATCH 29/32] fix permissions and don't send if has map open --- app/models/user.rb | 12 ++++++++++++ app/services/notification_service.rb | 11 +++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 2c7ddf13..bb22f972 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -132,6 +132,18 @@ class User < ApplicationRecord stars.where(map_id: map.id).exists? end + def has_map_open(map) + latestEvent = Event.where(map: map, user: self) + .where(kind: ['user_present_on_map', 'user_not_present_on_map']) + .order(:created_at) + .last + latestEvent && latestEvent.kind == 'user_present_on_map' + end + + def has_map_with_synapse_open(synapse) + synapse.maps.any?{|map| has_map_open(map)} + end + def settings self[:settings] = UserPreference.new if self[:settings].nil? if not self[:settings].respond_to?(:follow_topic_on_created) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 737dbb4d..c587e9ed 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -34,10 +34,13 @@ class NotificationService # we'll prbly want to put the body into the actual loop so we can pass the current user in as a local body = renderer.render(template: settings[:template], locals: { entity: entity, event: event }, layout: false) follows.each{|follow| - if entity.class == Map - next unless MapPolicy.new(follow.user, entity).show? - elsif entity.class == Topic - next unless TopicPolicy.new(follow.user, entity).show? + case event_type + when TOPIC_ADDED_TO_MAP + next unless TopicPolicy.new(follow.user, entity).show? && MapPolicy.new(follow.user, event.map).show? + next if follow.user.has_map_open(event.map) + when TOPIC_CONNECTED_1, TOPIC_CONNECTED_2 + next unless SynapsePolicy.new(follow.user, event).show? + next if follow.user.has_map_with_synapse_open(event) end # this handles email and in-app notifications, in the future, include push follow.user.notify(settings[:subject], body, event, false, event_type, follow.user.emails_allowed, event.user) From 780e66632b889690ef30ba083c70b5ab8a5cf4aa Mon Sep 17 00:00:00 2001 From: Devin Howard <devin@callysto.com> Date: Thu, 9 Mar 2017 11:23:24 -0800 Subject: [PATCH 30/32] fix react embedly (#1089) --- frontend/src/components/TopicCard/Attachments.js | 6 +++++- frontend/src/components/TopicCard/EmbedlyLink/index.js | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/TopicCard/Attachments.js b/frontend/src/components/TopicCard/Attachments.js index 3e04dfbc..6f26aed2 100644 --- a/frontend/src/components/TopicCard/Attachments.js +++ b/frontend/src/components/TopicCard/Attachments.js @@ -9,7 +9,11 @@ class Attachments extends Component { return ( <div className="attachments"> - <EmbedlyLink link={link} authorizedToEdit={authorizedToEdit} updateTopic={updateTopic} /> + <EmbedlyLink topicId={topic.id} + link={link} + authorizedToEdit={authorizedToEdit} + updateTopic={updateTopic} + /> </div> ) } diff --git a/frontend/src/components/TopicCard/EmbedlyLink/index.js b/frontend/src/components/TopicCard/EmbedlyLink/index.js index 1775ab03..c2413930 100644 --- a/frontend/src/components/TopicCard/EmbedlyLink/index.js +++ b/frontend/src/components/TopicCard/EmbedlyLink/index.js @@ -34,7 +34,7 @@ class EmbedlyLink extends Component { } render = () => { - const { link, authorizedToEdit } = this.props + const { link, authorizedToEdit, topicId } = this.props const { linkEdit } = this.state const hasAttachment = !!link @@ -55,7 +55,7 @@ class EmbedlyLink extends Component { {linkEdit && <div id="addLinkReset" onClick={this.resetLink}></div>} </div> </div> - {link && <Card link={link} />} + {link && <Card key={topicId} link={link} />} {authorizedToEdit && ( <div id="linkremove" style={{ display: hasAttachment ? 'block' : 'none' }} @@ -68,6 +68,7 @@ class EmbedlyLink extends Component { } EmbedlyLink.propTypes = { + topicId: PropTypes.number, link: PropTypes.string, authorizedToEdit: PropTypes.bool, updateTopic: PropTypes.func From e544d6a6dbb8cfd880c72211057b43720e75f7a4 Mon Sep 17 00:00:00 2001 From: Devin Howard <devin@callysto.com> Date: Thu, 9 Mar 2017 11:24:52 -0800 Subject: [PATCH 31/32] refactor api and fix bugs (#1088) * fix weird double-embed issue * fix users/current api if not logged in * turbocharge the api * fix docs --- app/controllers/api/v2/users_controller.rb | 1 + .../api/v2/application_serializer.rb | 61 +++++++++---------- app/serializers/api/v2/map_serializer.rb | 2 +- app/serializers/api/v2/mapping_serializer.rb | 2 +- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/app/controllers/api/v2/users_controller.rb b/app/controllers/api/v2/users_controller.rb index 3f60c410..092a95b5 100644 --- a/app/controllers/api/v2/users_controller.rb +++ b/app/controllers/api/v2/users_controller.rb @@ -3,6 +3,7 @@ module Api module V2 class UsersController < RestfulController def current + raise Pundit::NotAuthorizedError if current_user.nil? @user = current_user authorize @user show # delegate to the normal show function diff --git a/app/serializers/api/v2/application_serializer.rb b/app/serializers/api/v2/application_serializer.rb index 81772577..c0399db6 100644 --- a/app/serializers/api/v2/application_serializer.rb +++ b/app/serializers/api/v2/application_serializer.rb @@ -13,50 +13,49 @@ module Api @embeds ||= (scope[:embeds] || []).select { |e| self.class.embeddable.keys.include?(e) } end - # self.embeddable might look like this: - # creator: { attr: :first_creator, serializer: UserSerializer } - # contributors: { serializer: UserSerializer} - # This method will remove the :attr key if the underlying attribute name - # is different than the name provided in the final json output. All other keys - # in the hash will be passed to the ActiveModel::Serializer `attribute` method - # directly (e.g. serializer in the examples will be passed). - # - # This setup means if you passed this self.embeddable config and sent no - # ?embed= query param with your API request, you would get the regular attributes - # plus creator_id and contributor_ids. If you passed ?embed=creator,contributors - # then instead of an id and an array of ids, you would get a serialized user - # (the first_creator) and an array of serialized users (the contributors). + # Here's an example object that could be passed in self.embeddable: { + # creator: { + # serializer: UserSerializer, + # }, + # collaborators: { + # serializer: UserSerializer + # }, + # topic: {}, + # synapses: {} + # } + # The key has to be in embeddable or it won't show in the response, and the serializer is + # only needed if the key doesn't match a serializer def self.embed_dat embeddable.each_pair do |key, opts| - attr = opts.delete(:attr) || key - if attr.to_s.pluralize == attr.to_s - attribute("#{attr.to_s.singularize}_ids".to_sym, - opts.merge(unless: -> { embeds.include?(key) })) do - Pundit.policy_scope(scope[:current_user], object.send(attr))&.map(&:id) || [] + is_plural = key.to_s.pluralize == key.to_s + id_key = key.to_s.singularize + (is_plural ? '_ids' : '_id') + serializer = opts.delete(:serializer) || "Api::V2::#{key.to_s.singularize.camelize}Serializer".constantize + if is_plural + attribute(id_key.to_sym, opts.merge(unless: -> { embeds.include?(key) })) do + Pundit.policy_scope(scope[:current_user], object.send(key))&.map(&:id) || [] end - has_many(attr, opts.merge(if: -> { embeds.include?(key) })) do - list = Pundit.policy_scope(scope[:current_user], object.send(attr)) || [] - child_serializer = "Api::V2::#{attr.to_s.singularize.camelize}Serializer".constantize + has_many(key, opts.merge(if: -> { embeds.include?(key) })) do + list = Pundit.policy_scope(scope[:current_user], object.send(key)) || [] resource = ActiveModelSerializers::SerializableResource.new( list, - each_serializer: child_serializer, + each_serializer: serializer, scope: scope.merge(embeds: []) ) - resource.as_json + # resource.as_json will return e.g. { users: [ ... ] } for collaborators + # since we can't get the :users key, convert to an array and use .first.second to get the needed values + resource&.as_json&.to_a&.first&.second end else - id_opts = opts.merge(key: "#{key}_id") - attribute("#{attr}_id".to_sym, - id_opts.merge(unless: -> { embeds.include?(key) })) - attribute(key, opts.merge(if: -> { embeds.include?(key) })) do |serializer| - object = serializer.object.send(key) - child_serializer = "Api::V2::#{object.class.name}Serializer".constantize + attribute(id_key.to_sym, opts.merge(unless: -> { embeds.include?(key) })) + attribute(key, opts.merge(if: -> { embeds.include?(key) })) do |parent_serializer| + object = parent_serializer.object.send(key) + next nil if object.nil? resource = ActiveModelSerializers::SerializableResource.new( object, - serializer: child_serializer, + serializer: serializer, scope: scope.merge(embeds: []) ) - resource.as_json + resource&.as_json&.to_a&.first&.second end end end diff --git a/app/serializers/api/v2/map_serializer.rb b/app/serializers/api/v2/map_serializer.rb index 7e090d33..3ba158f9 100644 --- a/app/serializers/api/v2/map_serializer.rb +++ b/app/serializers/api/v2/map_serializer.rb @@ -18,7 +18,7 @@ module Api def self.embeddable { user: {}, - source: {}, + source: { serializer: MapSerializer }, topics: {}, synapses: {}, mappings: {}, diff --git a/app/serializers/api/v2/mapping_serializer.rb b/app/serializers/api/v2/mapping_serializer.rb index 30c9bd7f..d418b31c 100644 --- a/app/serializers/api/v2/mapping_serializer.rb +++ b/app/serializers/api/v2/mapping_serializer.rb @@ -14,7 +14,7 @@ module Api def self.embeddable { user: {}, - updated_by: {}, + updated_by: { serializer: UserSerializer }, map: {} } end From 77f76b1b5af4876f11043608dadd01cb704eb358 Mon Sep 17 00:00:00 2001 From: Connor Turland <connorturland@gmail.com> Date: Thu, 9 Mar 2017 14:36:24 -0500 Subject: [PATCH 32/32] variable name fix and make is_tester method global for reuse in views --- app/services/follow_service.rb | 6 +---- app/views/users/edit.html.erb | 34 +++++++++++++------------ config/initializers/internal_testers.rb | 3 +++ 3 files changed, 22 insertions(+), 21 deletions(-) create mode 100644 config/initializers/internal_testers.rb diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index e0d23002..8434baca 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -41,13 +41,9 @@ class FollowService if reason == 'created' return user.settings.follow_map_on_created == '1' elsif reason == 'contributed' - return user.settings.follow_map_contributed == '1' + return user.settings.follow_map_on_contributed == '1' end end end - - def is_tester(user) - %w(connorturland@gmail.com devin@callysto.com chessscholar@gmail.com solaureum@gmail.com ishanshapiro@gmail.com).include?(user.email) - end end end diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 1ee406e7..0bd2a366 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -45,22 +45,24 @@ <%= form.check_box :emails_allowed, class: 'inline' %> Send Metamaps notifications to my email. <% end %> - <%= fields_for :settings, @user.settings do |settings| %> - <%= settings.label :follow_topic_on_created, class: 'firstFieldText' do %> - <%= settings.check_box :follow_topic_on_created, class: 'inline' %> - Auto-follow topics you create. - <% end %> - <%= settings.label :follow_topic_on_contributed, class: 'firstFieldText' do %> - <%= settings.check_box :follow_topic_on_contributed, class: 'inline' %> - Auto-follow topics you edit. - <% end %> - <%= settings.label :follow_map_on_created, class: 'firstFieldText' do %> - <%= settings.check_box :follow_map_on_created, class: 'inline' %> - Auto-follow maps you create. - <% end %> - <%= settings.label :follow_map_on_contributed, class: 'firstFieldText' do %> - <%= settings.check_box :follow_map_on_contributed, class: 'inline' %> - Auto-follow maps you edit. + <% if is_tester(@user) %> + <%= fields_for :settings, @user.settings do |settings| %> + <%= settings.label :follow_topic_on_created, class: 'firstFieldText' do %> + <%= settings.check_box :follow_topic_on_created, class: 'inline' %> + Auto-follow topics you create. + <% end %> + <%= settings.label :follow_topic_on_contributed, class: 'firstFieldText' do %> + <%= settings.check_box :follow_topic_on_contributed, class: 'inline' %> + Auto-follow topics you edit. + <% end %> + <%= settings.label :follow_map_on_created, class: 'firstFieldText' do %> + <%= settings.check_box :follow_map_on_created, class: 'inline' %> + Auto-follow maps you create. + <% end %> + <%= settings.label :follow_map_on_contributed, class: 'firstFieldText' do %> + <%= settings.check_box :follow_map_on_contributed, class: 'inline' %> + Auto-follow maps you edit. + <% end %> <% end %> <% end %> </div> diff --git a/config/initializers/internal_testers.rb b/config/initializers/internal_testers.rb new file mode 100644 index 00000000..079fc119 --- /dev/null +++ b/config/initializers/internal_testers.rb @@ -0,0 +1,3 @@ +def is_tester(user) + user && %w(connorturland@gmail.com devin@callysto.com chessscholar@gmail.com solaureum@gmail.com ishanshapiro@gmail.com).include?(user.email) +end