diff --git a/.agignore b/.agignore new file mode 100644 index 00000000..a6d6b86c --- /dev/null +++ b/.agignore @@ -0,0 +1 @@ +app/assets/javascripts/metamaps.secret.bundle.js 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 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/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/assets/stylesheets/application.scss.erb b/app/assets/stylesheets/application.scss.erb index 3b2b0fd6..0c7b9976 100644 --- a/app/assets/stylesheets/application.scss.erb +++ b/app/assets/stylesheets/application.scss.erb @@ -1250,7 +1250,7 @@ h3.filterBox { box-shadow: 0px 3px 3px rgba(0,0,0,0.12), 0 3px 3px rgba(0,0,0,0.24); } .rightclickmenu .rc-permission:hover > ul, -.rightclickmenu .rc-metacode:hover > ul, +.rightclickmenu .rc-metacode:hover #metacodeOptions > ul, .rightclickmenu .rc-siblings:hover > ul { display: block; } @@ -1279,7 +1279,7 @@ h3.filterBox { .rightclickmenu li.toPrivate .rc-perm-icon { background-position: -24px 0; } -.rightclickmenu .rc-metacode > ul > li, +.rightclickmenu .rc-metacode #metacodeOptions > ul > li, .rightclickmenu .rc-siblings > ul > li { padding: 6px 24px 6px 8px; white-space: nowrap; @@ -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; @@ -3121,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/app/assets/stylesheets/base.scss.erb b/app/assets/stylesheets/base.scss.erb index 283e1db0..355f5652 100644 --- a/app/assets/stylesheets/base.scss.erb +++ b/app/assets/stylesheets/base.scss.erb @@ -6,6 +6,11 @@ font-family: helvetica; color: #727272; line-height: 11px; + display: none; +} + +.riek-editing + .nameCounter { + display: block; } .nameCounter.forMap { @@ -14,14 +19,14 @@ } .nameCounter.forTopic { - + } #center-container { position:relative; height:100%; width:100%; - + /* background-color:#031924; */ color:#444; } @@ -85,6 +90,11 @@ display: table-cell; vertical-align: middle; padding: 0 16px; + + &.riek-editing { + position: absolute; + top: 32px; + } } .canEdit #titleActivator:hover { background-image: url(<%= asset_data_uri('edit.png') %>); @@ -93,12 +103,12 @@ cursor: text; } -.showcard .best_in_place_name textarea, .showcard .best_in_place_name input { +.showcard .title .riek-editing { font-family: 'din-regular', sans-serif; color: #424242; font-size: 18px; line-height: 22px; - height: 15px; + height: 3em; padding: 5px 0; width: 100%; margin: 0; @@ -122,7 +132,7 @@ height: auto; } -.CardOnGraph .best_in_place_desc textarea { +.CardOnGraph .desc .riek-editing { font-size: 13px; line-height:15px; font-family: helvetica, sans-serif; @@ -167,13 +177,14 @@ * End Markdown styling */ -.CardOnGraph .best_in_place_desc { +.CardOnGraph .riek_desc { display:block; - margin-top:2px; + margin-top:2px; padding-right: 18px; margin-right: 8px; + min-height: 7em; } -.canEdit .CardOnGraph .best_in_place_desc:hover { +.canEdit .CardOnGraph .riek_desc:hover { background-image: url(<%= asset_data_uri('edit.png') %>); background-position: top right; background-repeat: no-repeat; @@ -185,155 +196,218 @@ } .CardOnGraph .links { - position:relative; + position: relative; border-bottom: 1px solid #BDBDBD; border-top: 1px solid #BDBDBD; background-color: #e0e0e0; -} -.linkItem { - float:left; - height:46px; - z-index: 1; - position: relative; - color: #424242; - font-size: 14px; - line-height:14px; - height:12px; - padding:17px 0; -} -.linkItem a { - color: #424242; -} + .linkItem { + float: left; + z-index: 1; + position: relative; + color: #424242; + font-size: 14px; + line-height: 14px; -.CardOnGraph .icon { - position:absolute; - width:100%; - z-index:1; - padding: 0; - height: 48px; -} -.linkItem.contributor { - margin-left:40px; - z-index:1; - padding:17px 16px 17px 30px; - position: relative; -} -.contributor .contributorIcon { - position: absolute; - top: 8px; - left: 0; - border-radius: 16px; -} + a { + color: #424242; + } + } -.contributor:hover .contributorName { - display: block; -} + .icon { + position: absolute; + z-index: 1; + padding: 0; + height: 48px; + width: 100%; -.contributorName { - display: none; - position: absolute; - background: black; - text-align: center; - color: white; - border-radius: 2px; - font-family: din-regular; - line-height: 15px; - font-size: 12px; - padding: 3px 5px 2px; - white-space: nowrap; - margin-top: 36px; - margin-left: -32px; -} + .metacodeImage { + cursor: move; + position: relative; + left: -23px; + top: 1px; + width: 46px; + height: 46px; + background-size:46px 46px; + background-position:0 0; + background-repeat:no-repeat; + } + } + + .contributor { + bottom: 7px; + margin-left: 40px; -.contributor div:before { - content: ''; - position: absolute; - top: 128%; - left: 13px; - margin-top: -30px; - width: 0; - height: 0; - border-bottom: 4px solid #000000; - border-left: 5px solid transparent; - border-right: 5px solid transparent; -} + .contributorIcon { + position: relative; + vertical-align: middle; + border-radius: 16px; + margin: 5px; + top: 8px; + left: 0; + border-radius: 16px; + } -.linkItem.mapCount { - margin-left: 12px; - width: 24px; - padding:17px 0 17px 36px; -} -.linkItem.mapCount .mapCountIcon { - position: absolute; - top: 8px; - left: 0; - width: 32px; - height: 32px; - background-image: url(<%= asset_data_uri('map32_sprite.png') %>); - background-repeat: no-repeat; - background-position: 0 0; - cursor: pointer; -} -.linkItem.mapCount:hover .mapCountIcon { - background-position: 0 -32px; -} + span { + font-family: 'din-regular', sans-serif; + font-size: 14px; + } -.linkItem.mapCount:hover .hoverTip { - display: block; -} -.CardOnGraph .mapCount .tip, .CardonGraph .mapCount .hoverTip { - top: 44px; - left: 0px; - font-size: 12px !important; -} + .contributorName { + display: none; + position: absolute; + background: black; + text-align: center; + color: white; + border-radius: 2px; + font-family: din-regular; + line-height: 15px; + font-size: 12px; + padding: 3px 5px 2px; + white-space: nowrap; + margin-top: 8px; -.hoverTip { - white-space: nowrap; - font-family: 'din-regular'; - top: 44px; - left: 0px; - font-size: 12px !important; - display: none; - position: absolute; - background: black; - color: white; - border-radius: 4px; - line-height: 17px; - padding: 3px 5px 2px; - z-index: 100; -} + &:before { + content: ''; + position: absolute; + top: 26px; + left: 10px; + margin-top: -30px; + width: 0; + height: 0; + border-bottom: 4px solid #000000; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + } + } + &:hover .contributorName { + display: block; + } + } -.CardOnGraph .mapCount .tip:before, .CardOnGraph .mapCount .hoverTip:before { - content: ''; - position: absolute; - top: 26px; - left: 10px; - margin-top: -30px; - width: 0; - height: 0; - border-bottom: 4px solid #000000; - border-left: 5px solid transparent; - border-right: 5px solid transparent; -} + .mapCount { + padding:17px 0 17px 36px; + margin-left: 12px; -.CardOnGraph .mapCount .tip li { - list-style-type: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding: 6px 10px; - display: block; - height: 14px; - font-family: 'din-regular', helvetica, sans-serif; - font-size: 14px; - line-height: 14px; - position: relative; -} + .mapCountIcon { + position: absolute; + top: 8px; + left: 0; + width: 32px; + height: 32px; + background-image: url(<%= asset_data_uri('map32_sprite.png') %>); + background-repeat: no-repeat; + background-position: 0 0; + cursor: pointer; + } -.CardOnGraph .mapCount li.hideExtra { - display: none; + &:hover .mapCountIcon { + background-position: 0 -32px; + } + + .tip, .hoverTip { + top: 44px; + left: 0px; + font-size: 12px !important; + + &:before { + content: ''; + position: absolute; + top: 26px; + left: 10px; + margin-top: -30px; + width: 0; + height: 0; + border-bottom: 4px solid #000000; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + } + } + + .hoverTip { + white-space: nowrap; + font-family: 'din-regular'; + top: 44px; + left: 0px; + font-size: 12px !important; + position: absolute; + background: black; + color: white; + border-radius: 4px; + line-height: 17px; + padding: 3px 5px 2px; + z-index: 100; + } + + .tip a { + color: white; + } + + .tip a:hover { + color: #757575; + } + + .tip li { + list-style-type: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 6px 10px; + display: block; + height: 14px; + font-family: 'din-regular', helvetica, sans-serif; + font-size: 14px; + line-height: 14px; + position: relative; + } + } + + .synapseCount { + margin-left: 26px; + width: 24px; + padding:17px 0 17px 32px; + + .synapseCountIcon { + position: absolute; + top: 8px; + left: 0; + width: 32px; + height: 32px; + background-image: url(<%= asset_data_uri('synapse32_sprite.png') %>); + background-repeat: no-repeat; + background-position: 0 0; + } + hover .synapseCountIcon { + background-position: 0 -32px; + } + .tip { + position: absolute; + background: black; + width: auto; + top: 44px; + color: white; + white-space: nowrap; + border-radius: 2px; + font-size: 12px !important; + font-family: 'din-regular'; + line-height: 12px; + padding: 4px 4px 4px; + z-index: 100; + } + + .tip:before { + content: ''; + position: absolute; + margin-top: -8px; + margin-left: 6px; + width: 0; + height: 0; + border-bottom: 4px solid black; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + } + } } .showMore { @@ -341,66 +415,6 @@ color: #4FC059; } -.mapCount .tip a { - color: white; -} - -.mapCount .tip a:hover { - color: #757575; -} - - -.linkItem.synapseCount { - margin-left: 2px; - width: 24px; - padding:17px 0 17px 32px; -} -.linkItem.synapseCount .synapseCountIcon { - position: absolute; - top: 8px; - left: 0; - width: 32px; - height: 32px; - background-image: url(<%= asset_data_uri('synapse32_sprite.png') %>); - background-repeat: no-repeat; - background-position: 0 0; -} -.linkItem.synapseCount:hover .synapseCountIcon { - background-position: 0 -32px; -} - -.CardOnGraph .synapseCount .tip { - position: absolute; - background: black; - width: auto; - top: 44px; - color: white; - white-space: nowrap; - border-radius: 2px; - font-size: 12px !important; - font-family: 'din-regular'; - line-height: 12px; - padding: 4px 4px 4px; - z-index: 100; -} - -.CardOnGraph .synapseCount:hover .tip { - display: block; -} - -.CardOnGraph .synapseCount .tip:before { - content: ''; - position: absolute; - margin-top: -8px; - margin-left: 6px; - width: 0; - height: 0; - border-bottom: 4px solid black; - border-left: 5px solid transparent; - border-right: 5px solid transparent; -} - - .mapPerm { width: 32px; height: 32px; @@ -470,7 +484,7 @@ cursor: pointer; text-transform: uppercase; position: absolute; line-height: 24px; - height:24px; + height: 26px; font-size: 24px; display: none; width: 90%; @@ -493,35 +507,25 @@ cursor: pointer; background-position: 0 -32px; } .permission.canEdit .minimize .expandMetacodeSelect { - + } -.CardOnGraph .metacodeImage { - cursor:move; - width:46px; - height:46px; - position:absolute; - left:-23px; - top:0; - background-size:46px 46px; - background-position:0 0; - background-repeat:no-repeat; +.CardOnGraph .metacodeName { + display: inline-block; } -#metacodeOptions { - display:none; -} .CardOnGraph .metacodeSelect { display:none; width:auto; z-index: 2; - position: absolute; background: #EAEAEA; - left: 300px; white-space: nowrap; + position: absolute; + left: 300px; + top: -1px; } .CardOnGraph .metacodeSelect ul { - position: relative; + position: relative; line-height: 14px; font-size: 14px; font-family: helvetica, sans-serif; @@ -610,7 +614,6 @@ background-color: #E0E0E0; display:block; } .CardOnGraph .tip { - display:none; position: absolute; background: black; top: 35px; @@ -623,26 +626,24 @@ background-color: #E0E0E0; z-index:100; } -#embedlyLink { - display: none; -} #embedlyLinkLoader { margin: 0 auto; width: 28px; } -.CardOnGraph .attachments { - border-top: 1px solid #BDBDBD; +.CardOnGraph .link-adder { width:100%; height:47px; + position: relative; + border-top: 1px solid #BDBDBD; } -.attachments a { +.link-adder a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; - margin-left: 40px; + margin-left: 40px; padding-top:9px; font-size: 16px; line-height: 16px; @@ -652,7 +653,7 @@ background-color: #E0E0E0; display: inline-block; width: 102px; height: 12px; - text-align: left; + text-align: left; padding: 18px 0 18px 48px; font-size: 12px; color: #9e9e9e; @@ -752,7 +753,6 @@ font-family: 'din-regular', helvetica, sans-serif; -moz-border-radius-bottomright: 8px; -webkit-border-bottom-right-radius: 8px; border-bottom-right-radius: 8px; - display: none; margin: 8px; } @@ -839,10 +839,10 @@ font-family: 'din-regular', helvetica, sans-serif; line-height: 16px; } -.canEdit #edit_synapse_desc:hover { +.canEdit span.titleWrapper:hover { background-image: url(<%= asset_data_uri('edit.png') %>); background-repeat: no-repeat; - background-position: 164px center; + background-position: 95% 95%; cursor: text; } @@ -950,11 +950,11 @@ font-family: 'din-regular', helvetica, sans-serif; } #edit_synapse_right { background-image: url(<%= asset_data_uri('synapsedirectionright_sprite.png') %>); - right: 16px; + right: 16px; } #edit_synapse_left { background-image: url(<%= asset_data_uri('synapsedirectionleft_sprite.png') %>); - right: 56px; + right: 56px; } #edit_synapse_left.checked, #edit_synapse_right.checked { background-position: 0 -48px; diff --git a/app/controllers/maps_controller.rb b/app/controllers/maps_controller.rb index a91d576d..c5b9870a 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, :unfollow_from_email] after_action :verify_authorized # GET maps/:id @@ -138,6 +138,43 @@ 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 + + # GET maps/:id/unfollow_from_email + def unfollow_from_email + FollowService.unfollow(@map, current_user) + + respond_to do |format| + format.html do + redirect_to map_path(@map), notice: 'You are no longer following this map' + end + end + end + private def set_map diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index b09767d0..c992bd14 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -2,7 +2,10 @@ 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, :unfollow_from_email] after_action :verify_authorized, except: :autocomplete_topic respond_to :html, :js, :json @@ -31,9 +34,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 +49,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 +68,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 +88,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 +140,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 +154,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 +161,50 @@ 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 + + # GET topics/:id/unfollow_from_email + def unfollow_from_email + FollowService.unfollow(@topic, current_user) + + respond_to do |format| + format.html do + redirect_to topic_path(@topic), notice: 'You are no longer following this topic' + 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/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7d0f4f68..2bb1c925 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,55 +1,5 @@ # frozen_string_literal: true module ApplicationHelper - def metacodeset - metacodes = current_user.settings.metacodes - - return false unless metacodes[0].include?('metacodeset') - return 'Most' if metacodes[0].sub('metacodeset-', '') == 'Most' - return 'Recent' if metacodes[0].sub('metacodeset-', '') == 'Recent' - - MetacodeSet.find(metacodes[0].sub('metacodeset-', '').to_i) - end - - def user_metacodes - @m = current_user.settings.metacodes - set = metacodeset - @metacodes = if set && set == 'Most' - Metacode.where(id: current_user.most_used_metacodes).to_a - elsif set && set == 'Recent' - Metacode.where(id: current_user.recent_metacodes).to_a - elsif set - set.metacodes.to_a - else - Metacode.where(id: @m).to_a - end - - focus_code = user_metacode() - if focus_code != nil && @metacodes.index{|m| m.id == focus_code.id} == nil - @metacodes.push(focus_code) - end - - @metacodes - .sort! { |m1, m2| m2.name.downcase <=> m1.name.downcase } - - if focus_code != nil - @metacodes.rotate!(@metacodes.index{|m| m.id == focus_code.id}) - else - @metacodes.rotate!(-1) - end - end - - def user_metacode - current_user.settings.metacode_focus ? Metacode.find(current_user.settings.metacode_focus.to_i) : nil - end - - def user_most_used_metacodes - @metacodes = current_user.most_used_metacodes.map { |id| Metacode.find(id) } - end - - def user_recent_metacodes - @metacodes = current_user.recent_metacodes.map { |id| Metacode.find(id) } - end - def invite_link "#{request.base_url}/join" + (current_user ? "?code=#{current_user.code}" : '') end diff --git a/app/helpers/metacode_sets_helper.rb b/app/helpers/metacode_sets_helper.rb deleted file mode 100644 index 9a6e09c7..00000000 --- a/app/helpers/metacode_sets_helper.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true -module MetacodeSetsHelper -end diff --git a/app/helpers/metacodes_helper.rb b/app/helpers/metacodes_helper.rb index d00f1ef5..bcdd8dd2 100644 --- a/app/helpers/metacodes_helper.rb +++ b/app/helpers/metacodes_helper.rb @@ -1,3 +1,78 @@ # frozen_string_literal: true module MetacodesHelper + def metacodeset + metacodes = current_user.settings.metacodes + + return false unless metacodes[0].include?('metacodeset') + return 'Most' if metacodes[0].sub('metacodeset-', '') == 'Most' + return 'Recent' if metacodes[0].sub('metacodeset-', '') == 'Recent' + + MetacodeSet.find(metacodes[0].sub('metacodeset-', '').to_i) + end + + def user_metacodes + @m = current_user.settings.metacodes + set = metacodeset + @metacodes = if set && set == 'Most' + Metacode.where(id: current_user.most_used_metacodes).to_a + elsif set && set == 'Recent' + Metacode.where(id: current_user.recent_metacodes).to_a + elsif set + set.metacodes.to_a + else + Metacode.where(id: @m).to_a + end + + focus_code = user_metacode + if !focus_code.nil? && @metacodes.index { |m| m.id == focus_code.id }.nil? + @metacodes.push(focus_code) + end + + @metacodes.sort! { |m1, m2| m2.name.downcase <=> m1.name.downcase } + + if !focus_code.nil? + @metacodes.rotate!(@metacodes.index { |m| m.id == focus_code.id }) + else + @metacodes.rotate!(-1) + end + end + + def user_metacode + current_user.settings.metacode_focus ? Metacode.find(current_user.settings.metacode_focus.to_i) : nil + end + + def user_most_used_metacodes + @metacodes = current_user.most_used_metacodes.map { |id| Metacode.find(id) } + end + + def user_recent_metacodes + @metacodes = current_user.recent_metacodes.map { |id| Metacode.find(id) } + end + + def metacode_sets_json + metacode_sets = [] + metacode_sets << { + name: 'Recently Used', + metacodes: user_recent_metacodes + .map { |m| { id: m.id, icon_path: asset_path(m.icon), name: m.name } } + } + metacode_sets << { + name: 'Most Used', + metacodes: user_most_used_metacodes + .map { |m| { id: m.id, icon_path: asset_path(m.icon), name: m.name } } + } + metacode_sets += MetacodeSet.order('name').all.map do |set| + { + name: set.name, + metacodes: set.metacodes.order('name') + .map { |m| { id: m.id, icon_path: asset_path(m.icon), name: m.name } } + } + end + metacode_sets << { + name: 'All', + metacodes: Metacode.order('name').all + .map { |m| { id: m.id, icon_path: asset_path(m.icon), name: m.name } } + } + metacode_sets.to_json + end end 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/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..6325b256 100644 --- a/app/policies/map_policy.rb +++ b/app/policies/map_policy.rb @@ -90,4 +90,16 @@ class MapPolicy < ApplicationPolicy def unstar? user.present? end + + def follow? + show? && user.present? + end + + def unfollow? + user.present? + end + + def unfollow_from_email? + user.present? + end end diff --git a/app/policies/topic_policy.rb b/app/policies/topic_policy.rb index bc80f657..c283f469 100644 --- a/app/policies/topic_policy.rb +++ b/app/policies/topic_policy.rb @@ -55,6 +55,18 @@ class TopicPolicy < ApplicationPolicy show? end + def follow? + show? && user.present? + end + + def unfollow? + user.present? + end + + def unfollow_from_email? + user.present? + end + # Helpers def map_policy @map_policy ||= Pundit.policy(user, record.defer_to_map) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 9c0693de..53add1cf 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -10,11 +10,11 @@ class FollowService follow.follow_reason.update_attribute(reason, true) end end - + 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 @@ -25,9 +25,9 @@ class FollowService 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 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/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/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" %> + + +
+

Hey <%= @user.name %>, there was activity by others in the last 24 hours on map + <%= link_to @map.name, map_url(@map) %> +

+

# of messages: <%= @summary_data[:stats][:messages_sent] || 0 %>

+

# of topics added: <%= @summary_data[:stats][:topics_added] || 0 %>

+

# of topics moved: <%= @summary_data[:stats][:topics_moved] || 0%>

+

# of topics removed: <%= @summary_data[:stats][:topics_removed] || 0 %>

+

# of synapses added: <%= @summary_data[:stats][:synapses_added] || 0 %>

+

# of synapses removed: <%= @summary_data[:stats][:synapses_removed] || 0 %>

+
+ <% if @summary_data[:topics_added] %> +

Topics Added

+ + <% end %> + + <% if @summary_data[:topics_removed] %> +

Topics Removed

+ + <% end %> + + <% if @summary_data[:synapses_added] %> +

Synapses Added

+ + <% end %> + + <% if @summary_data[:synapses_removed] %> +

Synapses Removed

+ + <% end %> + + <%= link_to 'Visit Map', map_url(@map), style: button_style %> + +
+

Make sense with Metamaps

+ <%= link_to 'Unfollow this map', unfollow_from_email_map_url(@map) %> + <%= render partial: 'shared/mailer_unsubscribe_link' %> +
diff --git a/app/views/map_mailer/_unfollow.html.erb b/app/views/map_mailer/_unfollow.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/map_mailer/_unfollow.text.erb b/app/views/map_mailer/_unfollow.text.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/shared/_cheatsheet.html.erb b/app/views/shared/_cheatsheet.html.erb index 52b95804..1ceca774 100644 --- a/app/views/shared/_cheatsheet.html.erb +++ b/app/views/shared/_cheatsheet.html.erb @@ -26,9 +26,11 @@
+
Enter Topic (radial) View: Click on a Topic result from Search, or click the synapse icon inside open Topic Card on map
Recenter Topics around chosen Topic: Alt + click on the topic OR Alt + E
Reveal the siblings for a Topic: Right-click and choose 'Reveal siblings' OR Alt + R
Center topic and reveal siblings: Alt + T
+
Filter out visible Topics: Open Filter menu *** and toggle off/on
@@ -44,19 +46,19 @@
- Open 'Topic' card: Double-click on topic icon + Open Topic card: Double-click on topic icon
- Move 'Topic' card: Click and drag on topic card metacode + Move Topic card: Click and drag on topic card metacode
- Change/edit metacode: Mouse over metacode icon, then click on solid colored bar + Change metacode: Mouse over metacode icon, then click on solid colored bar for metacode menu
- Edit Topic title, description, link: Click on text in respective area + Edit Topic title, description, link: Click on text in respective area (click small "X" to reset link)
- Save Topic title, description, link: Hit enter + Save Topic title, description, link: Hit enter, or click away
Change Topic permission: Click on 'Permission' icon (only for topic creator) @@ -65,35 +67,34 @@ Open Topic view: Click on icon within topic card bar
- Close 'Topic' card: Click on canvas + Close Topic card: Click on canvas
- Open 'Context Menu': Right-click/alt+click on topic icon or synapse + Open 'Context Menu': Right-click/alt+click on topic icon or synapse or selection (multiple) to Hide/Remove/Delete, change metacode or permission
-
*Hide/Remove/Delete topic within context menu
Open 'Create Synapse' prompt: Right-click & drag from one topic to another
-
Enter or Tab: Create synapse
-
Esc or Delete: Cancel synapse creation
-
*You do not have to add a description
-
Create new Topic with Synapse: Right-click + drag from topic to open canvas
-
Enter: Create topic
-
Enter: Create synapse
+
Enter a label Begin typing (or leave blank)
+
Confirm new Synapse: Enter or Tab
+
Cancel new Synapse: Escape or Delete
+
Create new Topic with Synapse: Right-click + drag from existing topic to open canvas
+
Create Topic: Same as elsewhere
+
Create Synapse: Same as above
-
Open 'Synapse' card: Double-click on Synapse
+
Open Synapse card: Double-click on Synapse
Edit Synapse description: Click on current description text
Save Synapse description: Hit enter
Edit directionality: Select appropriate arrow boxes
Change synapse permission: Click on 'permission' icon (only for synapse creator)
-
Browse synapses / change visible synapse click on arrow icon and select desired synapse
+
Browse / select from multiple (stacked) synapses: Click dropdown icon and select desired synapse
Open 'Context Menu': Right-click/alt-click on Synapse
*Hide/Remove/Delete synapse within context menu
@@ -102,8 +103,10 @@
Move around Canvas: Click and drag
-
Zoom in/out: Scroll OR click on
&
-
Zoom to see all: Click
OR Ctrl + E
+
Zoom in/out: Scroll OR click on
&
+
Zoom to see all: Click
OR Ctrl + E
+
Filter Map Contents: Open the Filter Menu *** and toggle items off/on
+
Return to 'Explore Maps' (home) page: Click the Metamaps logo in the upper left corner
@@ -111,8 +114,8 @@
Select/Deselect Topic: Click on topic icon
Select/Deselect Synapse: Click on synapse
-
Select multiple Topics/Synapses: Shift + click
-
Make Selection box, select multiple Topics/Synapses: Right-click/Shift-click + drag on Canvas
+
Select multiple Topics/Synapses: Shift + click to include each
+
Select multiple with Selection Box: Right-click/Shift-click + drag on Canvas
Move all selected Topics & Synapses: Click + drag on selected topic(s)/synapse(s)
Open 'Context Menu': Right-click/Alt-click on selected topic(s)
*Hide/Remove/Delete/Change permissions of multiple topics & synapses within context menu
@@ -121,11 +124,10 @@
-
Open 'Search' prompt: Ctrl + /
-
Close 'Search' prompt: Esc
- <% if controller_name == "maps" && action_name == "show" %> -
Add to current Map: Click "+" on a topic result
- <% end %> +
Search for Topics and Maps: Type query terms into search bar, wait for results below
+
Limit search results: Click checkbox for only items you created; click arrow above Topics or Maps section to collapse
+
Add Topic to current Map: Click "+" on a topic result
+
Jump to Topic View: Click anywhere else on a topic result
Search by metacode: type "[name of metacode]:", then your search query. i.e. idea:create...
Search for map: type "map:", then your search query. i.e. map:exploring...
Search for mapper: type "mapper:", then your search query. i.e. mapper:Robert
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 @@ 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/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 @@
<% end %>
-

Choose Your Metacodes

+
Choose Your Metacodes
+
NONE
+
ALL
<% @list = '' %> <% metacodesInUse = user_metacodes() %> <% Metacode.order("name").all.each_with_index do |m, index| %> @@ -116,4 +118,4 @@ \ No newline at end of file + diff --git a/app/views/topic_mailer/_unfollow.html.erb b/app/views/topic_mailer/_unfollow.html.erb new file mode 100644 index 00000000..555722a9 --- /dev/null +++ b/app/views/topic_mailer/_unfollow.html.erb @@ -0,0 +1,3 @@ +
+You are receiving this email because you are following this topic. +<%= link_to 'Unfollow', unfollow_from_email_topic_url(topic) %> \ No newline at end of file diff --git a/app/views/topic_mailer/_unfollow.text.erb b/app/views/topic_mailer/_unfollow.text.erb new file mode 100644 index 00000000..78128bd4 --- /dev/null +++ b/app/views/topic_mailer/_unfollow.text.erb @@ -0,0 +1,2 @@ +You are receiving this email because you are following this topic. +To unfollow, go to: <%= unfollow_from_email_topic_url(topic) %> \ No newline at end of file diff --git a/app/views/topic_mailer/added_to_map.html.erb b/app/views/topic_mailer/added_to_map.html.erb index a2435272..5cdb4301 100644 --- a/app/views/topic_mailer/added_to_map.html.erb +++ b/app/views/topic_mailer/added_to_map.html.erb @@ -8,4 +8,6 @@

<%= link_to 'Go to Topic', topic_url(topic), style: button_style %> -<%= link_to 'Go to Map', map_url(event.map), style: button_style %> \ No newline at end of file +<%= link_to 'Go to Map', map_url(event.map), style: button_style %> + +<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic } %> \ No newline at end of file diff --git a/app/views/topic_mailer/added_to_map.text.erb b/app/views/topic_mailer/added_to_map.text.erb index 183d1e8e..67518cc6 100644 --- a/app/views/topic_mailer/added_to_map.text.erb +++ b/app/views/topic_mailer/added_to_map.text.erb @@ -3,4 +3,6 @@ <%= event.user.name %> added topic <%= topic.name %> to map <%= event.map.name %> topic_url(topic) -map_url(event.map) \ No newline at end of file +map_url(event.map) + +<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic } %> \ No newline at end of file diff --git a/app/views/topic_mailer/connected.html.erb b/app/views/topic_mailer/connected.html.erb index 306e40c4..65e08c04 100644 --- a/app/views/topic_mailer/connected.html.erb +++ b/app/views/topic_mailer/connected.html.erb @@ -12,4 +12,6 @@ <% end %>

-<%= link_to 'View the connection', topic_url(topic1), style: button_style %> \ No newline at end of file +<%= link_to 'View the connection', topic_url(topic1), style: button_style %> + +<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic1 } %> \ No newline at end of file diff --git a/app/views/topic_mailer/connected.text.erb b/app/views/topic_mailer/connected.text.erb index 33afd027..e3688025 100644 --- a/app/views/topic_mailer/connected.text.erb +++ b/app/views/topic_mailer/connected.text.erb @@ -5,4 +5,6 @@ <%= synapse.user.name %> connected topic <%= topic1.name %> to topic <%= topic2.name %> <%= synapse.desc.length > 0 ? ' with the description' + synapse.desc : '' %> -<%= topic_url(topic1) %> \ No newline at end of file +<%= topic_url(topic1) %> + +<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic1 } %> \ No newline at end of file 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/config/routes.rb b/config/routes.rb index f2850c81..80c0dbb4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,6 +48,9 @@ Metamaps::Application.routes.draw do post :star, to: 'stars#create', default: { format: :json } post :unstar, to: 'stars#destroy', default: { format: :json } + post :follow, default: { format: :json } + post :unfollow, default: { format: :json } + get :unfollow_from_email end end @@ -83,6 +86,9 @@ Metamaps::Application.routes.draw do get :network get :relative_numbers get :relatives + post :follow, default: { format: :json } + post :unfollow, default: { format: :json } + get :unfollow_from_email end collection do get :autocomplete_topic 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/DataModel/Map.js b/frontend/src/Metamaps/DataModel/Map.js index 76c5eff3..b64f9f89 100644 --- a/frontend/src/Metamaps/DataModel/Map.js +++ b/frontend/src/Metamaps/DataModel/Map.js @@ -34,6 +34,9 @@ const Map = Backbone.Model.extend({ return false } }, + isFollowedBy: function(mapper) { + return mapper.get('follows') && mapper.get('follows').maps.indexOf(this.get('id')) > -1 + }, getUser: function() { return Mapper.get(this.get('user_id')) }, diff --git a/frontend/src/Metamaps/DataModel/Mapper.js b/frontend/src/Metamaps/DataModel/Mapper.js index f772c288..dc5e5f0b 100644 --- a/frontend/src/Metamaps/DataModel/Mapper.js +++ b/frontend/src/Metamaps/DataModel/Mapper.js @@ -5,7 +5,7 @@ import outdent from 'outdent' const Mapper = Backbone.Model.extend({ urlRoot: '/users', - blacklist: ['created_at', 'updated_at'], + blacklist: ['created_at', 'updated_at', 'follows'], toJSON: function(options) { return _.omit(this.attributes, this.blacklist) }, @@ -15,6 +15,20 @@ const Mapper = Backbone.Model.extend({ ${this.get('name')}

${this.get('name')}

` + }, + followMap: function(id) { + 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) + }, + unfollowTopic: function(id) { + const idIndex = this.get('follows').topics.indexOf(id) + if (idIndex > -1) this.get('follows').topics.splice(idIndex, 1) } }) diff --git a/frontend/src/Metamaps/DataModel/Topic.js b/frontend/src/Metamaps/DataModel/Topic.js index a9e53b5d..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' @@ -47,6 +47,9 @@ const Topic = Backbone.Model.extend({ if (mapper && this.get('user_id') === mapper.get('id')) return true else return false }, + isFollowedBy: function(mapper) { + return mapper.get('follows') && mapper.get('follows').topics.indexOf(this.get('id')) > -1 + }, getDate: function() {}, getMetacode: function() { return DataModel.Metacodes.get(this.get('metacode_id')) 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/Metamaps/Import.js b/frontend/src/Metamaps/Import.js index a7aae5ae..9830eace 100644 --- a/frontend/src/Metamaps/Import.js +++ b/frontend/src/Metamaps/Import.js @@ -29,8 +29,8 @@ const Import = { handleCSV: function(text, parserOpts = {}) { const self = Import - const topicsRegex = /("?Topics"?)([\s\S]*)/mi - const synapsesRegex = /("?Synapses"?)([\s\S]*)/mi + const topicsRegex = /("?Topics"?[, \t"]*)([\s\S]*)/mi + const synapsesRegex = /("?Synapses"?[, \t"]*)([\s\S]*)/mi let topicsText = text.match(topicsRegex) || '' if (topicsText) topicsText = topicsText[2].replace(synapsesRegex, '') let synapsesText = text.match(synapsesRegex) || '' 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/Listeners.js b/frontend/src/Metamaps/Listeners.js index c3b644df..d55abf92 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' @@ -31,11 +32,18 @@ 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 ((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() 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/PasteInput.js b/frontend/src/Metamaps/PasteInput.js index b72ae545..03a92f86 100644 --- a/frontend/src/Metamaps/PasteInput.js +++ b/frontend/src/Metamaps/PasteInput.js @@ -20,6 +20,10 @@ const PasteInput = { }, false) window.addEventListener('drop', function(e) { e = e || window.event + + // prevent conflict with react-dropzone file uploader + if (event.target.id !== 'infovis-canvas') return + e.preventDefault() var coords = Util.pixelsToCoords(Visualize.mGraph, { x: e.clientX, y: e.clientY }) if (e.dataTransfer.files.length > 0) { 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 562843e7..e371e4e0 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 } @@ -181,6 +198,37 @@ const Util = { }) } return text + }, + 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/Metamaps/Views/ExploreMaps.js b/frontend/src/Metamaps/Views/ExploreMaps.js index 0b7d841a..5c10691b 100644 --- a/frontend/src/Metamaps/Views/ExploreMaps.js +++ b/frontend/src/Metamaps/Views/ExploreMaps.js @@ -53,6 +53,20 @@ const ExploreMaps = { url: `/maps/${map.id}/access_request` }) GlobalUI.notifyUser('You will be notified by email if request accepted') + }, + onFollow: function(map) { + const isFollowing = map.isFollowedBy(Active.Mapper) + $.post({ + url: `/maps/${map.id}/${isFollowing ? 'un' : ''}follow` + }) + if (isFollowing) { + GlobalUI.notifyUser('You are no longer following this map') + Active.Mapper.unfollowMap(map.id) + } else { + GlobalUI.notifyUser('You are now following this map') + Active.Mapper.followMap(map.id) + } + self.render() } } ReactDOM.render( diff --git a/frontend/src/Metamaps/Views/TopicCard.js b/frontend/src/Metamaps/Views/TopicCard.js new file mode 100644 index 00000000..0b02fccd --- /dev/null +++ b/frontend/src/Metamaps/Views/TopicCard.js @@ -0,0 +1,74 @@ +/* global $ */ + +import React from 'react' +import ReactDOM from 'react-dom' + +import Active from '../Active' +import Visualize from '../Visualize' +import GlobalUI from '../GlobalUI' + +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) }) + }, + 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() + } + }), + 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/ImportDialogBox.js b/frontend/src/components/ImportDialogBox.js index 85b3ee67..9a9c777b 100644 --- a/frontend/src/components/ImportDialogBox.js +++ b/frontend/src/components/ImportDialogBox.js @@ -2,18 +2,8 @@ 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]) } @@ -21,13 +11,13 @@ class ImportDialogBox extends Component { return (

    EXPORT

    -
    +
    Export as CSV
    -
    +
    Export as JSON
    -
    +
    Download screenshot

    IMPORT

    @@ -45,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/src/components/Maps/MapCard.js b/frontend/src/components/Maps/MapCard.js index 1ed7e492..4dd9ea18 100644 --- a/frontend/src/components/Maps/MapCard.js +++ b/frontend/src/components/Maps/MapCard.js @@ -1,5 +1,6 @@ import React, { Component, PropTypes } from 'react' import { find, values } from 'lodash' +import Util from '../../Metamaps/Util' const IN_CONVERSATION = 1 // shared with /realtime/reducer.js @@ -23,7 +24,8 @@ class Menu extends Component { } render = () => { - const { currentUser, map, onStar, onRequest } = this.props + const { currentUser, map, onStar, onRequest, onFollow } = this.props + const isFollowing = map.isFollowedBy(currentUser) const style = { display: this.state.open ? 'block' : 'none' } return
    @@ -35,6 +37,7 @@ class Menu extends Component {
    • { this.toggle() && onStar(map) }}>Star Map
    • { !map.authorizeToEdit(currentUser) &&
    • { this.toggle() && onRequest(map) }}>Request Access
    • } + { Util.isTester(currentUser) &&
    • { this.toggle() && onFollow(map) }}>{isFollowing ? 'Unfollow' : 'Follow'}
    • }
    } @@ -43,7 +46,8 @@ Menu.propTypes = { currentUser: PropTypes.object.isRequired, map: PropTypes.object.isRequired, onStar: PropTypes.func.isRequired, - onRequest: PropTypes.func.isRequired + onRequest: PropTypes.func.isRequired, + onFollow: PropTypes.func.isRequired } const Metadata = (props) => { @@ -80,7 +84,7 @@ const checkAndWrapInA = (shouldWrap, classString, mapId, element) => { class MapCard extends Component { render = () => { - const { map, mobile, juntoState, currentUser, onRequest, onStar } = this.props + const { map, mobile, juntoState, currentUser, onRequest, onStar, onFollow } = this.props const hasMap = (juntoState.liveMaps[map.id] && values(juntoState.liveMaps[map.id]).length) || null const realtimeMap = juntoState.liveMaps[map.id] @@ -131,7 +135,7 @@ class MapCard extends Component {
    ) } { !mobile && hasMapper &&
    } { !mobile && hasConversation &&
    } - { !mobile && currentUser && } + { !mobile && currentUser && }
    ) }
    @@ -145,7 +149,8 @@ MapCard.propTypes = { juntoState: PropTypes.object, currentUser: PropTypes.object, onStar: PropTypes.func.isRequired, - onRequest: PropTypes.func.isRequired + onRequest: PropTypes.func.isRequired, + onFollow: PropTypes.func.isRequired } export default MapCard diff --git a/frontend/src/components/Maps/index.js b/frontend/src/components/Maps/index.js index 0478c34a..18d0fe92 100644 --- a/frontend/src/components/Maps/index.js +++ b/frontend/src/components/Maps/index.js @@ -46,7 +46,7 @@ class Maps extends Component { } render = () => { - const { maps, currentUser, juntoState, pending, section, user, onStar, onRequest } = this.props + const { maps, currentUser, juntoState, pending, section, user, onStar, onRequest, onFollow } = this.props const style = { width: this.state.mapsWidth + 'px' } const mobile = document && document.body.clientWidth <= MOBILE_VIEW_BREAKPOINT @@ -56,7 +56,7 @@ class Maps extends Component {
    { user ? : null } { currentUser && !user && !(pending && maps.length === 0) ? : null } - { maps.models.map(map => ) } + { maps.models.map(map => ) }
    @@ -79,7 +79,8 @@ Maps.propTypes = { loadMore: PropTypes.func, pending: PropTypes.bool.isRequired, onStar: PropTypes.func.isRequired, - onRequest: PropTypes.func.isRequired + onRequest: PropTypes.func.isRequired, + onFollow: PropTypes.func.isRequired } export default Maps 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/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
    + {isFollowing ? 'Unfollow' : 'Follow'} +
    + } +} + +Follow.propTypes = { + isFollowing: PropTypes.bool, + onFollow: PropTypes.func +} + +export default Follow 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..2c30d45e --- /dev/null +++ b/frontend/src/components/TopicCard/index.js @@ -0,0 +1,71 @@ +import React, { PropTypes, Component } from 'react' + +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, onFollow } = this.props + const authorizedToEdit = topic.authorizeToEdit(ActiveMapper) + const isFollowing = topic.isFollowedBy(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} + /> + {Util.isTester(ActiveMapper) && <Follow isFollowing={isFollowing} onFollow={onFollow} />} + <div className="clearfloat"></div> + </div> + </div> + ) + } +} + +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({ + id: PropTypes.number, + icon_path: PropTypes.string, // url + name: PropTypes.string + })) + })), + redrawCanvas: PropTypes.func +} + +export default ReactTopicCard diff --git a/frontend/src/patched/JIT.js b/frontend/src/patched/JIT.js index e780604e..5ea04ce1 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,132 @@ 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 + } + } + 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: 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 + } + if (30 >= desiredScale && desiredScale >= 0.2) { + Metamaps.Util.zoomOnPoint(this, scaler, midpoint) + jQuery(document).trigger(Metamaps.JIT.events.zoom) } - 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 - }, { - x: touch2.clientX, - y: touch2.clientY - }) - - if (!this.initDist) { - this.initDist = dist - this.initScale = canvas.scaleOffsetX - } - 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 (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 + }; + 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(); + } } // END METAMAPS CODE }); 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 91% rename from frontend/test/Metamaps.Util.spec.js rename to 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/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/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/package.json b/package.json index 104bcc5e..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,10 +45,12 @@ "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" + "webpack": "2.2.1" }, "devDependencies": { "babel-eslint": "^7.1.1", @@ -59,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/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/mailers/previews/map_mailer_preview.rb b/spec/mailers/previews/map_mailer_preview.rb index 05368443..9766c0da 100644 --- a/spec/mailers/previews/map_mailer_preview.rb +++ b/spec/mailers/previews/map_mailer_preview.rb @@ -6,12 +6,12 @@ class MapMailerPreview < ActionMailer::Preview MapMailer.invite_to_edit(user_map) end - def access_request_email + def access_request request = AccessRequest.first MapMailer.access_request(request) end - def access_approved_email + def access_approved request = AccessRequest.first MapMailer.access_approved(request) end diff --git a/spec/mailers/previews/topic_mailer_preview.rb b/spec/mailers/previews/topic_mailer_preview.rb new file mode 100644 index 00000000..7ce12b8d --- /dev/null +++ b/spec/mailers/previews/topic_mailer_preview.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# Preview all emails at http://localhost:3000/rails/mailers/topic_mailer +class TopicMailerPreview < ActionMailer::Preview + def added_to_map + event = Event.where(kind: 'topic_added_to_map').first + user = User.first + TopicMailer.added_to_map(event, user) + end + + def connected + synapse = Synapse.first + topic = synapse.topic1 + user = User.first + TopicMailer.connected(synapse, topic, 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 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