Into master: two finger pan/zoom, map and topic follows (for internal testing) on the UI, map activity emails (#1084)

* fix topic spec

* fix synapse/mapping spec

* brakeman csrf warning suppressed :|

* follows for maps in the ui for internal testing only still (#1072)

* follows for maps in the ui for testers

* require user for these actions

* match how map follow works

* include ability to unfollow from email

* fixup templates

* add unfollow_from_email to the policies

* Update _cheatsheet.html.erb

Clean up text, clarify, and bring in line with current functionality

* topicsRegex and synapsesRegex should allow commas (#1073)

* even better import csv regexes

* prevent double prompt on file drop import

* topic card in react (#1031)

* its coming along

* links bar

* scssify a bunch

* metacode image working a bit better

* metacode selector in react topic card

* riek editing for name field on topic card

* riek submit on enter

* factor out Title and Links from Topic Card component, but not the listeners

* create working Desc editor

* styling is much better now

* textarea min height for desc

* disallow images in topic card markdown

* shift enter is linebreak, enter is save

* attachments split out, but it's pretty buggy

* move listeners into Links.js

* slightly wider metacodeTitle

* fix positioning on metacode selector

* fix metacode selection

* move metacode and permissions into subcomponents

* fixes

* prevent editing on desc/title if not authorized to edit

* fix topic card draggability

* fix embedly

* fix md test

* remove the removed link card manually with jquery

* fix test syntax

* eslint

* more eslin

* reuse authorizedToEdit

* convert metacode sets to a json object for react

* add the html in react whoop

* fix metacode styling

* sort wasn't working

* finishing metacode select

* readd the above link input border

* fix syntax

* multiline title editable textarea

* more portable metacode selector component

* factor out #metacodeOptions into one react component with a callback :D:D:D

* render metacodeOptions in right click menu with react

* render metacodeOptions in right click menu with react

* fix up right click menu's metacode editing

* fix topic card title character counter

* ignore metamaps secret bundle in ag

* simplify Attachments props

* factor out embedly card into its own component; it seems to help

* link resetter

* fix edit icon on title in topic card

* move mapCount and synapseCount hover/click logic to react

* fix up the showMore control

* metacode selection tweaks

* tweak links bar spacing in topic card

* rubocop

* remove TODOs

* more badass permissions selector

* close permission selector when you click outside

* fix overeager metacode selector

* more modular attachments component

* fix bug in Desc.js

* fix right click styling

* permission changes are different than edit rights

* bad module ref

* ensure maxLength on topic titles

* hellz yeah (#1074)

* fix drop from two touches to one

* don't commit activity service

* ability to select/unselect all metacodes in custom set with keyboard shortcut (fix #390) (#1078)

* ability to select/unselect all metacodes in custom set with keyboard shortcut

* select all button

* nicer all/none buttons

* set up react testing (#1080)

* install mocha-webpack. also switch hark to npm version instead of github version

* well, mocha-webpack runs

* add jsdom for tests

* upgrade to webpack 2

* fix npm run test errors

* ImportDialogBox component tests

* Fixes bug where pressing delete key while editing text will suggest... (#1083)

* Fixes bug where pressing delete key while editing text will suggest the deletion of selected map entities

* Changed the DEL key to remove entities instead of delete them

* temporarily disable code climate duplication engine

* add topic following for internal testing

* daily map activity emails (#1081)

* data prepared, task setup

* add the basics of the email template

* cover granular permissions

* unfollow this map

* break out permissions tests better

* rename so test runs
This commit is contained in:
Connor Turland 2017-03-06 22:49:46 -05:00 committed by GitHub
parent 50639e8a0a
commit 7ee96bf6c6
84 changed files with 2463 additions and 1168 deletions

1
.agignore Normal file
View file

@ -0,0 +1 @@
app/assets/javascripts/metamaps.secret.bundle.js

View file

@ -5,7 +5,7 @@ engines:
bundler-audit: bundler-audit:
enabled: true enabled: true
duplication: duplication:
enabled: true enabled: false
config: config:
languages: languages:
count_threshold: 3 # rule of three count_threshold: 3 # rule of three

1
.gitignore vendored
View file

@ -22,6 +22,7 @@ app/assets/javascripts/webpacked
# Ignore all logfiles and tempfiles. # Ignore all logfiles and tempfiles.
log/*.log log/*.log
tmp tmp
.tmp
coverage coverage

View file

@ -51,4 +51,6 @@ group :development, :test do
gem 'pry-rails' gem 'pry-rails'
gem 'rubocop' gem 'rubocop'
gem 'tunemygc' gem 'tunemygc'
gem 'faker'
gem 'timecop'
end end

View file

@ -109,6 +109,8 @@ GEM
factory_girl_rails (4.8.0) factory_girl_rails (4.8.0)
factory_girl (~> 4.8.0) factory_girl (~> 4.8.0)
railties (>= 3.0.0) railties (>= 3.0.0)
faker (1.7.3)
i18n (~> 0.5)
globalid (0.3.7) globalid (0.3.7)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
httparty (0.14.0) httparty (0.14.0)
@ -272,6 +274,7 @@ GEM
thor (0.19.4) thor (0.19.4)
thread_safe (0.3.5) thread_safe (0.3.5)
tilt (2.0.5) tilt (2.0.5)
timecop (0.8.1)
tunemygc (1.0.69) tunemygc (1.0.69)
tzinfo (1.2.2) tzinfo (1.2.2)
thread_safe (~> 0.1) thread_safe (~> 0.1)
@ -301,6 +304,7 @@ DEPENDENCIES
dotenv-rails dotenv-rails
exception_notification exception_notification
factory_girl_rails factory_girl_rails
faker
httparty httparty
jquery-rails jquery-rails
jquery-ui-rails jquery-ui-rails
@ -327,6 +331,7 @@ DEPENDENCIES
slack-notifier slack-notifier
snorlax snorlax
sucker_punch sucker_punch
timecop
tunemygc tunemygc
uglifier uglifier
@ -334,4 +339,4 @@ RUBY VERSION
ruby 2.3.0p0 ruby 2.3.0p0
BUNDLED WITH BUNDLED WITH
1.13.7 1.14.6

View file

@ -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); 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-permission:hover > ul,
.rightclickmenu .rc-metacode:hover > ul, .rightclickmenu .rc-metacode:hover #metacodeOptions > ul,
.rightclickmenu .rc-siblings:hover > ul { .rightclickmenu .rc-siblings:hover > ul {
display: block; display: block;
} }
@ -1279,7 +1279,7 @@ h3.filterBox {
.rightclickmenu li.toPrivate .rc-perm-icon { .rightclickmenu li.toPrivate .rc-perm-icon {
background-position: -24px 0; background-position: -24px 0;
} }
.rightclickmenu .rc-metacode > ul > li, .rightclickmenu .rc-metacode #metacodeOptions > ul > li,
.rightclickmenu .rc-siblings > ul > li { .rightclickmenu .rc-siblings > ul > li {
padding: 6px 24px 6px 8px; padding: 6px 24px 6px 8px;
white-space: nowrap; white-space: nowrap;
@ -2311,6 +2311,9 @@ and it won't be important on password protected instances */
} }
/* switch metacode set */ /* switch metacode set */
#switchMetacodes > p {
margin: 16px 0 16px 0;
}
#metacodeSwitchTabs { #metacodeSwitchTabs {
width: 100%; width: 100%;
font-size: 17px; font-size: 17px;
@ -2318,28 +2321,43 @@ and it won't be important on password protected instances */
border: none; border: none;
background: none; background: none;
padding: 0; padding: 0;
}
#metacodeSwitchTabs .setDesc { .setDesc,
margin-bottom: 5px; .selectAll,
font-family: 'din-medium', helvetica, sans-serif; .selectNone {
color: #424242; margin-bottom: 5px;
font-size: 14px; font-family: 'din-medium', helvetica, sans-serif;
text-align: justify; color: #424242;
padding-right: 16px; font-size: 14px;
} text-align: justify;
#switchMetacodes > p { padding-right: 16px;
margin: 16px 0 16px 0; display: inline-block;
} }
#metacodeSwitchTabs > ul {
width: 130px; .selectAll,
} .selectNone {
#metacodeSwitchTabs > ul li { float: right;
font-size: 14px; cursor: pointer;
text-transform: uppercase;
} &:hover,
#metacodeSwitchTabs li.ui-state-active a { &.selected {
color: #00BCD4; color: #00bcd4;
cursor: pointer; }
}
& > ul {
width: 130px;
li {
font-size: 14px;
text-transform: uppercase;
}
}
li.ui-state-active a {
color: #00BCD4;
cursor: pointer;
}
} }
.metacodeSwitchTab { .metacodeSwitchTab {
max-height: 300px; max-height: 300px;
@ -3121,3 +3139,13 @@ script.data-gratipay-username {
.inline { .inline {
display: inline-block; 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;
}

View file

@ -6,6 +6,11 @@
font-family: helvetica; font-family: helvetica;
color: #727272; color: #727272;
line-height: 11px; line-height: 11px;
display: none;
}
.riek-editing + .nameCounter {
display: block;
} }
.nameCounter.forMap { .nameCounter.forMap {
@ -85,6 +90,11 @@
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
padding: 0 16px; padding: 0 16px;
&.riek-editing {
position: absolute;
top: 32px;
}
} }
.canEdit #titleActivator:hover { .canEdit #titleActivator:hover {
background-image: url(<%= asset_data_uri('edit.png') %>); background-image: url(<%= asset_data_uri('edit.png') %>);
@ -93,12 +103,12 @@
cursor: text; cursor: text;
} }
.showcard .best_in_place_name textarea, .showcard .best_in_place_name input { .showcard .title .riek-editing {
font-family: 'din-regular', sans-serif; font-family: 'din-regular', sans-serif;
color: #424242; color: #424242;
font-size: 18px; font-size: 18px;
line-height: 22px; line-height: 22px;
height: 15px; height: 3em;
padding: 5px 0; padding: 5px 0;
width: 100%; width: 100%;
margin: 0; margin: 0;
@ -122,7 +132,7 @@
height: auto; height: auto;
} }
.CardOnGraph .best_in_place_desc textarea { .CardOnGraph .desc .riek-editing {
font-size: 13px; font-size: 13px;
line-height:15px; line-height:15px;
font-family: helvetica, sans-serif; font-family: helvetica, sans-serif;
@ -167,13 +177,14 @@
* End Markdown styling * End Markdown styling
*/ */
.CardOnGraph .best_in_place_desc { .CardOnGraph .riek_desc {
display:block; display:block;
margin-top:2px; margin-top:2px;
padding-right: 18px; padding-right: 18px;
margin-right: 8px; 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-image: url(<%= asset_data_uri('edit.png') %>);
background-position: top right; background-position: top right;
background-repeat: no-repeat; background-repeat: no-repeat;
@ -185,155 +196,218 @@
} }
.CardOnGraph .links { .CardOnGraph .links {
position:relative; position: relative;
border-bottom: 1px solid #BDBDBD; border-bottom: 1px solid #BDBDBD;
border-top: 1px solid #BDBDBD; border-top: 1px solid #BDBDBD;
background-color: #e0e0e0; background-color: #e0e0e0;
}
.linkItem { .linkItem {
float:left; float: left;
height:46px; z-index: 1;
z-index: 1; position: relative;
position: relative; color: #424242;
color: #424242; font-size: 14px;
font-size: 14px; line-height: 14px;
line-height:14px;
height:12px;
padding:17px 0;
}
.linkItem a {
color: #424242;
}
.CardOnGraph .icon { a {
position:absolute; color: #424242;
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;
}
.contributor:hover .contributorName { .icon {
display: block; position: absolute;
} z-index: 1;
padding: 0;
height: 48px;
width: 100%;
.contributorName { .metacodeImage {
display: none; cursor: move;
position: absolute; position: relative;
background: black; left: -23px;
text-align: center; top: 1px;
color: white; width: 46px;
border-radius: 2px; height: 46px;
font-family: din-regular; background-size:46px 46px;
line-height: 15px; background-position:0 0;
font-size: 12px; background-repeat:no-repeat;
padding: 3px 5px 2px; }
white-space: nowrap; }
margin-top: 36px;
margin-left: -32px;
}
.contributor div:before { .contributor {
content: ''; bottom: 7px;
position: absolute; margin-left: 40px;
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;
}
.linkItem.mapCount { .contributorIcon {
margin-left: 12px; position: relative;
width: 24px; vertical-align: middle;
padding:17px 0 17px 36px; border-radius: 16px;
} margin: 5px;
.linkItem.mapCount .mapCountIcon { top: 8px;
position: absolute; left: 0;
top: 8px; border-radius: 16px;
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;
}
.linkItem.mapCount:hover .hoverTip { span {
display: block; font-family: 'din-regular', sans-serif;
} font-size: 14px;
.CardOnGraph .mapCount .tip, .CardonGraph .mapCount .hoverTip { }
top: 44px;
left: 0px;
font-size: 12px !important;
}
.hoverTip { .contributorName {
white-space: nowrap; display: none;
font-family: 'din-regular'; position: absolute;
top: 44px; background: black;
left: 0px; text-align: center;
font-size: 12px !important; color: white;
display: none; border-radius: 2px;
position: absolute; font-family: din-regular;
background: black; line-height: 15px;
color: white; font-size: 12px;
border-radius: 4px; padding: 3px 5px 2px;
line-height: 17px; white-space: nowrap;
padding: 3px 5px 2px; margin-top: 8px;
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;
}
}
.CardOnGraph .mapCount .tip:before, .CardOnGraph .mapCount .hoverTip:before { &:hover .contributorName {
content: ''; display: block;
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;
}
.CardOnGraph .mapCount .tip li { .mapCount {
list-style-type: none; padding:17px 0 17px 36px;
white-space: nowrap; margin-left: 12px;
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;
}
.CardOnGraph .mapCount li.hideExtra { .mapCountIcon {
display: none; 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;
}
&: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 { .showMore {
@ -341,66 +415,6 @@
color: #4FC059; 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 { .mapPerm {
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -470,7 +484,7 @@ cursor: pointer;
text-transform: uppercase; text-transform: uppercase;
position: absolute; position: absolute;
line-height: 24px; line-height: 24px;
height:24px; height: 26px;
font-size: 24px; font-size: 24px;
display: none; display: none;
width: 90%; width: 90%;
@ -496,29 +510,19 @@ cursor: pointer;
} }
.CardOnGraph .metacodeImage { .CardOnGraph .metacodeName {
cursor:move; display: inline-block;
width:46px;
height:46px;
position:absolute;
left:-23px;
top:0;
background-size:46px 46px;
background-position:0 0;
background-repeat:no-repeat;
} }
#metacodeOptions {
display:none;
}
.CardOnGraph .metacodeSelect { .CardOnGraph .metacodeSelect {
display:none; display:none;
width:auto; width:auto;
z-index: 2; z-index: 2;
position: absolute;
background: #EAEAEA; background: #EAEAEA;
left: 300px;
white-space: nowrap; white-space: nowrap;
position: absolute;
left: 300px;
top: -1px;
} }
.CardOnGraph .metacodeSelect ul { .CardOnGraph .metacodeSelect ul {
position: relative; position: relative;
@ -610,7 +614,6 @@ background-color: #E0E0E0;
display:block; display:block;
} }
.CardOnGraph .tip { .CardOnGraph .tip {
display:none;
position: absolute; position: absolute;
background: black; background: black;
top: 35px; top: 35px;
@ -623,21 +626,19 @@ background-color: #E0E0E0;
z-index:100; z-index:100;
} }
#embedlyLink {
display: none;
}
#embedlyLinkLoader { #embedlyLinkLoader {
margin: 0 auto; margin: 0 auto;
width: 28px; width: 28px;
} }
.CardOnGraph .attachments { .CardOnGraph .link-adder {
border-top: 1px solid #BDBDBD;
width:100%; width:100%;
height:47px; height:47px;
position: relative;
border-top: 1px solid #BDBDBD;
} }
.attachments a { .link-adder a {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -752,7 +753,6 @@ font-family: 'din-regular', helvetica, sans-serif;
-moz-border-radius-bottomright: 8px; -moz-border-radius-bottomright: 8px;
-webkit-border-bottom-right-radius: 8px; -webkit-border-bottom-right-radius: 8px;
border-bottom-right-radius: 8px; border-bottom-right-radius: 8px;
display: none;
margin: 8px; margin: 8px;
} }
@ -839,10 +839,10 @@ font-family: 'din-regular', helvetica, sans-serif;
line-height: 16px; line-height: 16px;
} }
.canEdit #edit_synapse_desc:hover { .canEdit span.titleWrapper:hover {
background-image: url(<%= asset_data_uri('edit.png') %>); background-image: url(<%= asset_data_uri('edit.png') %>);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 164px center; background-position: 95% 95%;
cursor: text; cursor: text;
} }

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class MapsController < ApplicationController class MapsController < ApplicationController
before_action :require_user, only: [:create, :update, :destroy, :events] before_action :require_user, only: [:create, :update, :destroy, :events, :follow, :unfollow]
before_action :set_map, only: [:show, :conversation, :update, :destroy, :contains, :events, :export] before_action :set_map, only: [:show, :conversation, :update, :destroy, :contains, :events, :export, :follow, :unfollow, :unfollow_from_email]
after_action :verify_authorized after_action :verify_authorized
# GET maps/:id # GET maps/:id
@ -138,6 +138,43 @@ class MapsController < ApplicationController
end end
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 private
def set_map def set_map

View file

@ -2,7 +2,10 @@
class TopicsController < ApplicationController class TopicsController < ApplicationController
include TopicsHelper 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 after_action :verify_authorized, except: :autocomplete_topic
respond_to :html, :js, :json respond_to :html, :js, :json
@ -31,9 +34,6 @@ class TopicsController < ApplicationController
# GET topics/:id # GET topics/:id
def show def show
@topic = Topic.find(params[:id])
authorize @topic
respond_to do |format| respond_to do |format|
format.html do format.html do
@alltopics = [@topic].concat(policy_scope(Topic.relatives(@topic.id, current_user)).to_a) @alltopics = [@topic].concat(policy_scope(Topic.relatives(@topic.id, current_user)).to_a)
@ -49,9 +49,6 @@ class TopicsController < ApplicationController
# GET topics/:id/network # GET topics/:id/network
def network def network
@topic = Topic.find(params[:id])
authorize @topic
@alltopics = [@topic].concat(policy_scope(Topic.relatives(@topic.id, current_user)).to_a) @alltopics = [@topic].concat(policy_scope(Topic.relatives(@topic.id, current_user)).to_a)
@allsynapses = policy_scope(Synapse.for_topic(@topic.id)) @allsynapses = policy_scope(Synapse.for_topic(@topic.id))
@ -71,9 +68,6 @@ class TopicsController < ApplicationController
# GET topics/:id/relative_numbers # GET topics/:id/relative_numbers
def relative_numbers def relative_numbers
@topic = Topic.find(params[:id])
authorize @topic
topics_already_has = params[:network] ? params[:network].split(',').map(&:to_i) : [] topics_already_has = params[:network] ? params[:network].split(',').map(&:to_i) : []
alltopics = policy_scope(Topic.relatives(@topic.id, current_user)).to_a alltopics = policy_scope(Topic.relatives(@topic.id, current_user)).to_a
@ -94,9 +88,6 @@ class TopicsController < ApplicationController
# GET topics/:id/relatives # GET topics/:id/relatives
def relatives def relatives
@topic = Topic.find(params[:id])
authorize @topic
topics_already_has = params[:network] ? params[:network].split(',').map(&:to_i) : [] topics_already_has = params[:network] ? params[:network].split(',').map(&:to_i) : []
alltopics = policy_scope(Topic.relatives(@topic.id, current_user)).to_a alltopics = policy_scope(Topic.relatives(@topic.id, current_user)).to_a
@ -149,8 +140,6 @@ class TopicsController < ApplicationController
# PUT /topics/1 # PUT /topics/1
# PUT /topics/1.json # PUT /topics/1.json
def update def update
@topic = Topic.find(params[:id])
authorize @topic
@topic.updated_by = current_user @topic.updated_by = current_user
@topic.assign_attributes(topic_params) @topic.assign_attributes(topic_params)
@ -165,8 +154,6 @@ class TopicsController < ApplicationController
# DELETE topics/:id # DELETE topics/:id
def destroy def destroy
@topic = Topic.find(params[:id])
authorize @topic
@topic.updated_by = current_user @topic.updated_by = current_user
@topic.destroy @topic.destroy
respond_to do |format| respond_to do |format|
@ -174,8 +161,50 @@ class TopicsController < ApplicationController
end end
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 private
def set_topic
@topic = Topic.find(params[:id])
authorize @topic
end
def topic_params def topic_params
params.require(:topic).permit(:id, :name, :desc, :link, :permission, :metacode_id, :defer_to_map_id) params.require(:topic).permit(:id, :name, :desc, :link, :permission, :metacode_id, :defer_to_map_id)
end end

View file

@ -1,55 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module ApplicationHelper 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 def invite_link
"#{request.base_url}/join" + (current_user ? "?code=#{current_user.code}" : '') "#{request.base_url}/join" + (current_user ? "?code=#{current_user.code}" : '')
end end

View file

@ -1,3 +0,0 @@
# frozen_string_literal: true
module MetacodeSetsHelper
end

View file

@ -1,3 +1,78 @@
# frozen_string_literal: true # frozen_string_literal: true
module MetacodesHelper 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 end

View file

@ -3,10 +3,6 @@ class ApplicationMailer < ActionMailer::Base
default from: 'team@metamaps.cc' default from: 'team@metamaps.cc'
layout 'mailer' layout 'mailer'
def deliver
raise NotImplementedError('Please use Mailboxer to send your emails.')
end
class << self class << self
def mail_for_notification(notification) def mail_for_notification(notification)
case notification.notification_code case notification.notification_code

View file

@ -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

View file

@ -41,7 +41,8 @@ class Mapping < ApplicationRecord
topic2: mappable.topic2.filtered, topic2: mappable.topic2.filtered,
mapping_id: id mapping_id: id
) )
Events::SynapseAddedToMap.publish!(mappable, map, user, nil) meta = { 'mapping_id': id }
Events::SynapseAddedToMap.publish!(mappable, map, user, meta)
end end
end end

View file

@ -52,10 +52,20 @@ class User < ApplicationRecord
# override default as_json # override default as_json
def as_json(_options = {}) def as_json(_options = {})
{ id: id, json = { id: id,
name: name, name: name,
image: image.url(:sixtyfour), image: image.url(:sixtyfour),
admin: admin } 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 end
def as_json_for_autocomplete def as_json_for_autocomplete

View file

@ -90,4 +90,16 @@ class MapPolicy < ApplicationPolicy
def unstar? def unstar?
user.present? user.present?
end end
def follow?
show? && user.present?
end
def unfollow?
user.present?
end
def unfollow_from_email?
user.present?
end
end end

View file

@ -55,6 +55,18 @@ class TopicPolicy < ApplicationPolicy
show? show?
end end
def follow?
show? && user.present?
end
def unfollow?
user.present?
end
def unfollow_from_email?
user.present?
end
# Helpers # Helpers
def map_policy def map_policy
@map_policy ||= Pundit.policy(user, record.defer_to_map) @map_policy ||= Pundit.policy(user, record.defer_to_map)

View file

@ -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

View file

@ -3,7 +3,7 @@
<%= render :partial => 'shared/metacodeBgColors' %> <%= render :partial => 'shared/metacodeBgColors' %>
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
<% if current_user %> <% if current_user %>
Metamaps.ServerData.ActiveMapper = <%= current_user.to_json.html_safe %> Metamaps.ServerData.ActiveMapper = <%= current_user.to_json({follows: true, email: true}).html_safe %>
<% else %> <% else %>
Metamaps.ServerData.ActiveMapper = null Metamaps.ServerData.ActiveMapper = null
<% end %> <% end %>

View file

@ -181,72 +181,4 @@
<div class="clearfloat"></div> <div class="clearfloat"></div>
</div> </div>
</script> </script>
<script type="text/template" id="topicCardTemplate">
<div class="CardOnGraph {{hasAttachment}}" id="topic_{{id}}">
<span class="title">
<div class="titleWrapper" id="titleActivator">
<span class="best_in_place best_in_place_name"
data-bip-url="/topics/{{id}}"
data-bip-object="topic"
data-bip-attribute="name"
data-bip-activator="#titleActivator"
data-bip-value="{{name}}"
data-bip-type="textarea"
>
{{name}}
</span>
</div>
</span>
<div class="links">
<div class="linkItem icon">
<div class="metacodeTitle {{metacode_class}}">
{{metacode}}
<div class="expandMetacodeSelect"></div>
</div>
<div class="metacodeImage" style="background-image:url({{imgsrc}});" title="click and drag to move card"></div>
<div class="metacodeSelect">{{{metacode_select}}}</div>
</div>
<div class="linkItem contributor">
<a href="/explore/mapper/{{userid}}" target="_blank"><img src="<%= asset_path('user.png') %>" class="contributorIcon" width="32" height="32" /></a>
<div class="contributorName">{{username}}</div>
</div>
<div class="linkItem mapCount">
<div class="mapCountIcon"></div>
{{map_count}}
<div class ="hoverTip">Click to see which maps topic appears on</div>
<div class="tip"><ul>{{{inmaps}}}</ul></div>
</div>
<a href="/topics/{{id}}" target="_blank" class="linkItem synapseCount">
<div class="synapseCountIcon"></div>
{{synapse_count}}
<div class="tip">Click to see this topics synapses</div>
</a>
<div class="linkItem mapPerm {{mk_permission}}" title="{{permission}}"></div>
<div class="clearfloat"></div>
</div>
<div class="scroll">
<div class="desc">
<span class="best_in_place best_in_place_desc"
data-bip-url="/topics/{{id}}"
data-bip-object="topic"
data-bip-nil="{{desc_nil}}"
data-bip-attribute="desc"
data-bip-type="textarea"
data-bip-value="{{desc_markdown}}"
>
{{{desc_html}}}
</span>
<div class="clearfloat"></div>
</div>
</div>
<div class="embeds">
{{{embeds}}}
</div>
<div class="attachments {{attachmentsHidden}}">
{{{attachments}}}
</div>
<div class="clearfloat"></div>
</div>
</script>
</div> </div>

View file

@ -0,0 +1,57 @@
<% button_style = "background-color:#4fc059;border-radius:2px;color:white;display:inline-block;font-family:Roboto,Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;min-height:29px;line-height:29px;min-width:54px;outline:0px;padding:0 8px;text-align:center;text-decoration:none" %>
<!DOCTYPE html>
<div style="padding: 16px; background: white; text-align: left; font-family: Arial">
<p>Hey <%= @user.name %>, there was activity by others in the last 24 hours on map
<%= link_to @map.name, map_url(@map) %>
</p>
<p># of messages: <%= @summary_data[:stats][:messages_sent] || 0 %></p>
<p># of topics added: <%= @summary_data[:stats][:topics_added] || 0 %></p>
<p># of topics moved: <%= @summary_data[:stats][:topics_moved] || 0%></p>
<p># of topics removed: <%= @summary_data[:stats][:topics_removed] || 0 %></p>
<p># of synapses added: <%= @summary_data[:stats][:synapses_added] || 0 %></p>
<p># of synapses removed: <%= @summary_data[:stats][:synapses_removed] || 0 %></p>
<hr>
<% if @summary_data[:topics_added] %>
<h2>Topics Added</h2>
<ul>
<% @summary_data[:topics_added].each do |event| %>
<li><%= event.eventable.name %></li>
<% end %>
</ul>
<% end %>
<% if @summary_data[:topics_removed] %>
<h2>Topics Removed</h2>
<ul>
<% @summary_data[:topics_removed].each do |event| %>
<li><%= event.eventable.name %></li>
<% end %>
</ul>
<% end %>
<% if @summary_data[:synapses_added] %>
<h2>Synapses Added</h2>
<ul>
<% @summary_data[:synapses_added].each do |event| %>
<li><%= event.eventable.topic1.name %> -> <%= event.eventable.topic2.name %></li>
<% end %>
</ul>
<% end %>
<% if @summary_data[:synapses_removed] %>
<h2>Synapses Removed</h2>
<ul>
<% @summary_data[:synapses_removed].each do |event| %>
<li><%= event.eventable.topic1.name %> -> <%= event.eventable.topic2.name %></li>
<% end %>
</ul>
<% end %>
<%= link_to 'Visit Map', map_url(@map), style: button_style %>
<hr>
<p style="font-size: 14px;">Make sense with Metamaps</p>
<%= link_to 'Unfollow this map', unfollow_from_email_map_url(@map) %>
<%= render partial: 'shared/mailer_unsubscribe_link' %>
</div>

View file

View file

View file

@ -26,9 +26,11 @@
</ul> </ul>
<div id="csTopicView"> <div id="csTopicView">
<div class="csItem"><span class="csTitle">Enter Topic (radial) View:</span> Click on a Topic result from Search, or click the synapse <img src="<%= asset_path 'synapse16.png' %>" width="16" align="middle" /> icon inside open Topic Card on map</div>
<div class="csItem"><span class="csTitle">Recenter Topics around chosen Topic:</span> Alt + click on the topic OR Alt + E</div> <div class="csItem"><span class="csTitle">Recenter Topics around chosen Topic:</span> Alt + click on the topic OR Alt + E</div>
<div class="csItem"><span class="csTitle">Reveal the siblings for a Topic:</span> Right-click and choose 'Reveal siblings' OR Alt + R</div> <div class="csItem"><span class="csTitle">Reveal the siblings for a Topic:</span> Right-click and choose 'Reveal siblings' OR Alt + R</div>
<div class="csItem"><span class="csTitle">Center topic and reveal siblings:</span> Alt + T</div> <div class="csItem"><span class="csTitle">Center topic and reveal siblings:</span> Alt + T</div>
<div class="csItem"><span class="csTitle">Filter out visible Topics:</span> Open Filter menu *** and toggle off/on</div>
</div> </div>
<div id="csCreatingTopics"> <div id="csCreatingTopics">
@ -44,19 +46,19 @@
<div id="csEditingTopics"> <div id="csEditingTopics">
<div class="csItem"> <div class="csItem">
<span class="csTitle">Open 'Topic' card:</span> Double-click on topic icon <span class="csTitle">Open Topic card:</span> Double-click on topic icon
</div> </div>
<div class="csItem indented"> <div class="csItem indented">
<span class="csTitle">Move 'Topic' card:</span> Click and drag on topic card metacode <span class="csTitle">Move Topic card:</span> Click and drag on topic card metacode
</div> </div>
<div class="csItem indented"> <div class="csItem indented">
<span class="csTitle">Change/edit metacode:</span> Mouse over metacode icon, then click on solid colored bar <span class="csTitle">Change metacode:</span> Mouse over metacode icon, then click on solid colored bar for metacode menu
</div> </div>
<div class="csItem indented"> <div class="csItem indented">
<span class="csTitle">Edit Topic title, description, link:</span> Click on text in respective area <span class="csTitle">Edit Topic title, description, link:</span> Click on text in respective area (click small "X" to reset link)
</div> </div>
<div class="csItem indented"> <div class="csItem indented">
<span class="csTitle">Save Topic title, description, link:</span> Hit enter <span class="csTitle">Save Topic title, description, link:</span> Hit enter, or click away
</div> </div>
<div class="csItem indented"> <div class="csItem indented">
<span class="csTitle">Change Topic permission:</span> Click on 'Permission' icon (only for topic creator) <span class="csTitle">Change Topic permission:</span> Click on 'Permission' icon (only for topic creator)
@ -65,35 +67,34 @@
<span class="csTitle">Open Topic view:</span> Click on <img src="<%= asset_path 'synapse16.png' %>" width="16" align="middle" /> icon within topic card bar <span class="csTitle">Open Topic view:</span> Click on <img src="<%= asset_path 'synapse16.png' %>" width="16" align="middle" /> icon within topic card bar
</div> </div>
<div class="csItem indented"> <div class="csItem indented">
<span class="csTitle">Close 'Topic' card:</span> Click on canvas <span class="csTitle">Close Topic card:</span> Click on canvas
</div> </div>
<div class="csItem"> <div class="csItem">
<span class="csTitle">Open 'Context Menu':</span> Right-click/alt+click on topic icon or synapse <span class="csTitle">Open 'Context Menu':</span> Right-click/alt+click on topic icon or synapse or selection (multiple) to Hide/Remove/Delete, change metacode or permission
</div> </div>
<div class="csItem indented">*Hide/Remove/Delete topic within context menu</div>
<div class="csItem"><br><a href="https://docs.metamaps.cc/creating_topics.html" target= "_blank">Learn More</a></div> <div class="csItem"><br><a href="https://docs.metamaps.cc/creating_topics.html" target= "_blank">Learn More</a></div>
</div> </div>
<div id="csCreatingSynapses"> <div id="csCreatingSynapses">
<div class="csItem"><span class="csTitle">Open 'Create Synapse' prompt:</span> Right-click & drag from one topic to another</div> <div class="csItem"><span class="csTitle">Open 'Create Synapse' prompt:</span> Right-click & drag from one topic to another</div>
<div class="csItem indented"><span class="csTitle">Enter or Tab:</span> Create synapse</div> <div class="csItem indented"><span class="csTitle">Enter a label</span> Begin typing (or leave blank)</div>
<div class="csItem indented"><span class="csTitle">Esc or Delete:</span> Cancel synapse creation</div> <div class="csItem indented"><span class="csTitle">Confirm new Synapse:</span> Enter or Tab</div>
<div class="csItem indented">*You do not have to add a description</div> <div class="csItem indented"><span class="csTitle">Cancel new Synapse:</span> Escape or Delete</div>
<div class="csItem"><span class="csTitle">Create new Topic with Synapse:</span> Right-click + drag from topic to open canvas</div> <div class="csItem"><span class="csTitle">Create new Topic with Synapse:</span> Right-click + drag from existing topic to open canvas</div>
<div class="csItem indented"><span class="csTitle">Enter:</span> Create topic</div> <div class="csItem indented"><span class="csTitle">Create Topic:</span> Same as elsewhere</div>
<div class="csItem indented"><span class="csTitle">Enter:</span> Create synapse</div> <div class="csItem indented"><span class="csTitle">Create Synapse:</span> Same as above</div>
<div class="csItem"><br><a href="https://docs.metamaps.cc/creating_synapses.html" target= "_blank">Learn More</a></div> <div class="csItem"><br><a href="https://docs.metamaps.cc/creating_synapses.html" target= "_blank">Learn More</a></div>
</div> </div>
<div id="csEditingSynapses"> <div id="csEditingSynapses">
<div class="csItem"><span class="csTitle">Open 'Synapse' card:</span> Double-click on Synapse </div> <div class="csItem"><span class="csTitle">Open Synapse card:</span> Double-click on Synapse </div>
<div class="csItem indented"><span class="csTitle">Edit Synapse description:</span> Click on current description text</div> <div class="csItem indented"><span class="csTitle">Edit Synapse description:</span> Click on current description text</div>
<div class="csItem indented"><span class="csTitle">Save Synapse description:</span> Hit enter</div> <div class="csItem indented"><span class="csTitle">Save Synapse description:</span> Hit enter</div>
<div class="csItem indented"><span class="csTitle">Edit directionality:</span> Select appropriate arrow boxes</div> <div class="csItem indented"><span class="csTitle">Edit directionality:</span> Select appropriate arrow boxes</div>
<div class="csItem indented"><span class="csTitle">Change synapse permission:</span> Click on 'permission' icon (only for synapse creator)</div> <div class="csItem indented"><span class="csTitle">Change synapse permission:</span> Click on 'permission' icon (only for synapse creator)</div>
<div class="csItem indented"><span class="csTitle">Browse synapses / change visible synapse</span> click on arrow icon and select desired synapse</div> <div class="csItem indented"><span class="csTitle">Browse / select from multiple (stacked) synapses:</span> Click dropdown icon and select desired synapse</div>
<div class="csItem"><span class="csTitle">Open 'Context Menu':</span> Right-click/alt-click on Synapse</div> <div class="csItem"><span class="csTitle">Open 'Context Menu':</span> Right-click/alt-click on Synapse</div>
<div class="csItem indented">*Hide/Remove/Delete synapse within context menu</div> <div class="csItem indented">*Hide/Remove/Delete synapse within context menu</div>
<div class="csItem"><br><a href="https://docs.metamaps.cc/creating_synapses.html" target= "_blank">Learn More</a></div> <div class="csItem"><br><a href="https://docs.metamaps.cc/creating_synapses.html" target= "_blank">Learn More</a></div>
@ -102,8 +103,10 @@
<div id="csNavigation"> <div id="csNavigation">
<div class="csItem"><span class="csTitle">Move around Canvas:</span> Click and drag</div> <div class="csItem"><span class="csTitle">Move around Canvas:</span> Click and drag</div>
<div class="csItem"><span class="csTitle">Zoom in/out:</span> Scroll OR click on <div id="zoomIn"> </div> & <div id="zoomOut"> </div></div> <div class="csItem indented"><span class="csTitle">Zoom in/out:</span> Scroll OR click on <div id="zoomIn"> </div> & <div id="zoomOut"> </div></div>
<div class="csItem"><span class="csTitle">Zoom to see all:</span> Click <div id="centerMap"></div> OR Ctrl + E</div> <div class="csItem indented"><span class="csTitle">Zoom to see all:</span> Click <div id="centerMap"></div> OR Ctrl + E</div>
<div class="csItem"><span class="csTitle">Filter Map Contents:</span> Open the Filter Menu *** and toggle items off/on</div>
<div class="csItem"><span class="csTitle">Return to 'Explore Maps' (home) page:</span> Click the Metamaps logo in the upper left corner</div>
<div class="csItem"><br><a href="https://docs.metamaps.cc/exploring_maps.html" target= "_blank">Learn More</a></div> <div class="csItem"><br><a href="https://docs.metamaps.cc/exploring_maps.html" target= "_blank">Learn More</a></div>
</div> </div>
@ -111,8 +114,8 @@
<div id="csSelection"> <div id="csSelection">
<div class="csItem"><span class="csTitle">Select/Deselect Topic:</span> Click on topic icon</div> <div class="csItem"><span class="csTitle">Select/Deselect Topic:</span> Click on topic icon</div>
<div class="csItem"><span class="csTitle">Select/Deselect Synapse:</span> Click on synapse</div> <div class="csItem"><span class="csTitle">Select/Deselect Synapse:</span> Click on synapse</div>
<div class="csItem"><span class="csTitle">Select multiple Topics/Synapses:</span> Shift + click</div> <div class="csItem"><span class="csTitle">Select multiple Topics/Synapses:</span> Shift + click to include each</div>
<div class="csItem"><span class="csTitle">Make Selection box, select multiple Topics/Synapses:</span> Right-click/Shift-click + drag on Canvas</div> <div class="csItem"><span class="csTitle">Select multiple with Selection Box:</span> Right-click/Shift-click + drag on Canvas</div>
<div class="csItem"><span class="csTitle">Move all selected Topics & Synapses:</span> Click + drag on selected topic(s)/synapse(s)</div> <div class="csItem"><span class="csTitle">Move all selected Topics & Synapses:</span> Click + drag on selected topic(s)/synapse(s)</div>
<div class="csItem"><span class="csTitle">Open 'Context Menu':</span> Right-click/Alt-click on selected topic(s)</div> <div class="csItem"><span class="csTitle">Open 'Context Menu':</span> Right-click/Alt-click on selected topic(s)</div>
<div class="csItem indented">*Hide/Remove/Delete/Change permissions of multiple topics & synapses within context menu</div> <div class="csItem indented">*Hide/Remove/Delete/Change permissions of multiple topics & synapses within context menu</div>
@ -121,11 +124,10 @@
</div> </div>
<div id="csSearch"> <div id="csSearch">
<div class="csItem"><span class="csTitle">Open 'Search' prompt:</span> Ctrl + /</div> <div class="csItem"><span class="csTitle">Search for Topics and Maps:</span> Type query terms into search bar, wait for results below</div>
<div class="csItem"><span class="csTitle">Close 'Search' prompt:</span> Esc</div> <div class="csItem"><span class="csTitle">Limit search results:</span> Click checkbox for only items you created; click arrow above Topics or Maps section to collapse</div>
<% if controller_name == "maps" && action_name == "show" %> <div class="csItem"><span class="csTitle">Add Topic to current Map:</span> Click "+" on a topic result</div>
<div class="csItem"><span class="csTitle">Add to current Map:</span> Click "+" on a topic result</div> <div class="csItem"><span class="csTitle">Jump to Topic View:</span> Click anywhere else on a topic result</div>
<% end %>
<div class="csItem"><span class="csTitle">Search by metacode:</span> type "[name of metacode]:", then your search query. i.e. idea:create...</div> <div class="csItem"><span class="csTitle">Search by metacode:</span> type "[name of metacode]:", then your search query. i.e. idea:create...</div>
<div class="csItem"><span class="csTitle">Search for map:</span> type "map:", then your search query. i.e. map:exploring...</div> <div class="csItem"><span class="csTitle">Search for map:</span> type "map:", then your search query. i.e. map:exploring...</div>
<div class="csItem"><span class="csTitle">Search for mapper:</span> type "mapper:", then your search query. i.e. mapper:Robert</div> <div class="csItem"><span class="csTitle">Search for mapper:</span> type "mapper:", then your search query. i.e. mapper:Robert</div>

View file

@ -1,3 +1,3 @@
<div class="unsubscribe-link"> <div class="unsubscribe-link">
<%= link_to 'Click here to unsubscribe from all Metamaps emails', unsubscribe_notifications_url(protocol: Rails.env.production? ? :https : :http) %> <%= link_to 'Unsubscribe from all Metamaps emails', unsubscribe_notifications_url(protocol: Rails.env.production? ? :https : :http) %>
</div> </div>

View file

@ -3,61 +3,7 @@
# this code generates the list of icons that will drop down in the metacode select list on the topic card # this code generates the list of icons that will drop down in the metacode select list on the topic card
#%> #%>
<div id="metacodeOptions"> <script>
<ul> Metamaps.ServerData = Metamaps.ServerData || {}
<li> Metamaps.ServerData.metacodeSets = <%= raw metacode_sets_json %>
<span>Recently Used</span> </script>
<div class="expandMetacodeSet"></div>
<ul>
<% user_recent_metacodes().each do |m| %>
<li data-id="<%= m.id.to_s %>">
<img width="24" height="24" src="<%= asset_path m.icon %>" alt="<%= m.name %>" />
<div class="mSelectName"><%= m.name %></div>
<div class="clearfloat"></div>
</li>
<% end %>
</ul>
</li>
<li>
<span>Most Used</span>
<div class="expandMetacodeSet"></div>
<ul>
<% user_most_used_metacodes().each do |m| %>
<li data-id="<%= m.id.to_s %>">
<img width="24" height="24" src="<%= asset_path m.icon %>" alt="<%= m.name %>" />
<div class="mSelectName"><%= m.name %></div>
<div class="clearfloat"></div>
</li>
<% end %>
</ul>
</li>
<% MetacodeSet.order("name").all.each do |set| %>
<li>
<span><%= set.name %></span>
<div class="expandMetacodeSet"></div>
<ul>
<% set.metacodes.sort { |a, b| a.name <=> b.name }.each do |m| %>
<li data-id="<%= m.id.to_s %>">
<img width="24" height="24" src="<%= asset_path m.icon %>" alt="<%= m.name %>" />
<div class="mSelectName"><%= m.name %></div>
<div class="clearfloat"></div>
</li>
<% end %>
</ul>
</li>
<% end %>
<li>
<span>All</span>
<div class="expandMetacodeSet"></div>
<ul>
<% Metacode.order("name").all.each do |m| %>
<li data-id="<%= m.id.to_s %>">
<img width="24" height="24" src="<%= asset_path m.icon %>" alt="<%= m.name %>" />
<div class="mSelectName"><%= m.name %></div>
<div class="clearfloat"></div>
</li>
<% end %>
</ul>
</li>
</ul>
</div>

View file

@ -91,7 +91,9 @@
</div> </div>
<% end %> <% end %>
<div id="metacodeSwitchTabsCustom"> <div id="metacodeSwitchTabsCustom">
<p class="setDesc">Choose Your Metacodes</p> <div class="setDesc">Choose Your Metacodes</div>
<div class="selectNone">NONE</div>
<div class="selectAll">ALL</div>
<% @list = '' %> <% @list = '' %>
<% metacodesInUse = user_metacodes() %> <% metacodesInUse = user_metacodes() %>
<% Metacode.order("name").all.each_with_index do |m, index| %> <% Metacode.order("name").all.each_with_index do |m, index| %>

View file

@ -0,0 +1,3 @@
<hr>
You are receiving this email because you are following this topic.
<%= link_to 'Unfollow', unfollow_from_email_topic_url(topic) %>

View file

@ -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) %>

View file

@ -9,3 +9,5 @@
<%= link_to 'Go to Topic', topic_url(topic), style: button_style %> <%= link_to 'Go to Topic', topic_url(topic), style: button_style %>
<%= link_to 'Go to Map', map_url(event.map), style: button_style %> <%= link_to 'Go to Map', map_url(event.map), style: button_style %>
<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic } %>

View file

@ -4,3 +4,5 @@
topic_url(topic) topic_url(topic)
map_url(event.map) map_url(event.map)
<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic } %>

View file

@ -13,3 +13,5 @@
</p> </p>
<%= link_to 'View the connection', topic_url(topic1), style: button_style %> <%= link_to 'View the connection', topic_url(topic1), style: button_style %>
<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic1 } %>

View file

@ -6,3 +6,5 @@
<%= synapse.desc.length > 0 ? ' with the description' + synapse.desc : '' %> <%= synapse.desc.length > 0 ? ' with the description' + synapse.desc : '' %>
<%= topic_url(topic1) %> <%= topic_url(topic1) %>
<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic1 } %>

View file

@ -15,8 +15,6 @@ MAP_ACCESS_REQUEST = 'ACCESS_REQUEST'
MAP_INVITE_TO_EDIT = 'INVITE_TO_EDIT' MAP_INVITE_TO_EDIT = 'INVITE_TO_EDIT'
# these ones are new # these ones are new
# this one's a catch all for occurences on the map
# MAP_ACTIVITY = 'MAP_ACTIVITY'
# MAP_RECEIVED_TOPIC # MAP_RECEIVED_TOPIC
# MAP_LOST_TOPIC # MAP_LOST_TOPIC
# MAP_TOPIC_MOVED # MAP_TOPIC_MOVED

View file

@ -48,6 +48,9 @@ Metamaps::Application.routes.draw do
post :star, to: 'stars#create', default: { format: :json } post :star, to: 'stars#create', default: { format: :json }
post :unstar, to: 'stars#destroy', 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
end end
@ -83,6 +86,9 @@ Metamaps::Application.routes.draw do
get :network get :network
get :relative_numbers get :relative_numbers
get :relatives get :relatives
post :follow, default: { format: :json }
post :unfollow, default: { format: :json }
get :unfollow_from_email
end end
collection do collection do
get :autocomplete_topic get :autocomplete_topic

View file

@ -28,6 +28,8 @@ const Create = {
}).addClass('ui-tabs-vertical ui-helper-clearfix') }).addClass('ui-tabs-vertical ui-helper-clearfix')
$('#metacodeSwitchTabs .ui-tabs-nav li').removeClass('ui-corner-top').addClass('ui-corner-left') $('#metacodeSwitchTabs .ui-tabs-nav li').removeClass('ui-corner-top').addClass('ui-corner-left')
$('.customMetacodeList li').click(self.toggleMetacodeSelected) // within the custom metacode set tab $('.customMetacodeList li').click(self.toggleMetacodeSelected) // within the custom metacode set tab
$('.selectAll').click(self.metacodeSelectorSelectAll)
$('.selectNone').click(self.metacodeSelectorSelectNone)
}, },
toggleMetacodeSelected: function() { toggleMetacodeSelected: function() {
var self = Create var self = Create
@ -43,6 +45,46 @@ const Create = {
self.newSelectedMetacodes.push($(this).attr('id')) self.newSelectedMetacodes.push($(this).attr('id'))
self.newSelectedMetacodeNames.push($(this).attr('data-name')) 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) { updateMetacodeSet: function(set, index, custom) {
if (custom && Create.newSelectedMetacodes.length === 0) { if (custom && Create.newSelectedMetacodes.length === 0) {
@ -114,7 +156,6 @@ const Create = {
} }
}) })
}, },
cancelMetacodeSetSwitch: function() { cancelMetacodeSetSwitch: function() {
var self = Create var self = Create
self.isSwitchingSet = false self.isSwitchingSet = false

View file

@ -34,6 +34,9 @@ const Map = Backbone.Model.extend({
return false return false
} }
}, },
isFollowedBy: function(mapper) {
return mapper.get('follows') && mapper.get('follows').maps.indexOf(this.get('id')) > -1
},
getUser: function() { getUser: function() {
return Mapper.get(this.get('user_id')) return Mapper.get(this.get('user_id'))
}, },

View file

@ -5,7 +5,7 @@ import outdent from 'outdent'
const Mapper = Backbone.Model.extend({ const Mapper = Backbone.Model.extend({
urlRoot: '/users', urlRoot: '/users',
blacklist: ['created_at', 'updated_at'], blacklist: ['created_at', 'updated_at', 'follows'],
toJSON: function(options) { toJSON: function(options) {
return _.omit(this.attributes, this.blacklist) return _.omit(this.attributes, this.blacklist)
}, },
@ -15,6 +15,20 @@ const Mapper = Backbone.Model.extend({
<img src="${this.get('image')}" data-id="${this.id}" alt="${this.get('name')}" /> <img src="${this.get('image')}" data-id="${this.id}" alt="${this.get('name')}" />
<p>${this.get('name')}</p> <p>${this.get('name')}</p>
</li>` </li>`
},
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)
} }
}) })

View file

@ -4,7 +4,7 @@ try { Backbone.$ = window.$ } catch (err) {}
import Active from '../Active' import Active from '../Active'
import Filter from '../Filter' import Filter from '../Filter'
import TopicCard from '../TopicCard' import TopicCard from '../Views/TopicCard'
import Visualize from '../Visualize' import Visualize from '../Visualize'
import DataModel from './index' import DataModel from './index'
@ -47,6 +47,9 @@ const Topic = Backbone.Model.extend({
if (mapper && this.get('user_id') === mapper.get('id')) return true if (mapper && this.get('user_id') === mapper.get('id')) return true
else return false else return false
}, },
isFollowedBy: function(mapper) {
return mapper.get('follows') && mapper.get('follows').topics.indexOf(this.get('id')) > -1
},
getDate: function() {}, getDate: function() {},
getMetacode: function() { getMetacode: function() {
return DataModel.Metacodes.get(this.get('metacode_id')) return DataModel.Metacodes.get(this.get('metacode_id'))

View file

@ -26,7 +26,10 @@ const ImportDialog = {
ReactDOM.render(React.createElement(ImportDialogBox, { ReactDOM.render(React.createElement(ImportDialogBox, {
onFileAdded: PasteInput.handleFile, onFileAdded: PasteInput.handleFile,
exampleImageUrl: serverData['import-example.png'], exampleImageUrl: serverData['import-example.png'],
downloadScreenshot: ImportDialog.downloadScreenshot downloadScreenshot: ImportDialog.downloadScreenshot,
onExport: format => {
window.open(`${window.location.pathname}/export.${format}`, '_blank')
}
}), $('.importDialogWrapper').get(0)) }), $('.importDialogWrapper').get(0))
}, },
show: function() { show: function() {

View file

@ -29,8 +29,8 @@ const Import = {
handleCSV: function(text, parserOpts = {}) { handleCSV: function(text, parserOpts = {}) {
const self = Import const self = Import
const topicsRegex = /("?Topics"?)([\s\S]*)/mi const topicsRegex = /("?Topics"?[, \t"]*)([\s\S]*)/mi
const synapsesRegex = /("?Synapses"?)([\s\S]*)/mi const synapsesRegex = /("?Synapses"?[, \t"]*)([\s\S]*)/mi
let topicsText = text.match(topicsRegex) || '' let topicsText = text.match(topicsRegex) || ''
if (topicsText) topicsText = topicsText[2].replace(synapsesRegex, '') if (topicsText) topicsText = topicsText[2].replace(synapsesRegex, '')
let synapsesText = text.match(synapsesRegex) || '' let synapsesText = text.match(synapsesRegex) || ''

View file

@ -2,9 +2,14 @@
import _ from 'lodash' import _ from 'lodash'
import outdent from 'outdent' import outdent from 'outdent'
import clipboard from 'clipboard-js'
import React from 'react'
import ReactDOM from 'react-dom'
import $jit from '../patched/JIT' import $jit from '../patched/JIT'
import MetacodeSelect from '../components/MetacodeSelect'
import Active from './Active' import Active from './Active'
import Control from './Control' import Control from './Control'
import Create from './Create' import Create from './Create'
@ -18,10 +23,9 @@ import Settings from './Settings'
import Synapse from './Synapse' import Synapse from './Synapse'
import SynapseCard from './SynapseCard' import SynapseCard from './SynapseCard'
import Topic from './Topic' import Topic from './Topic'
import TopicCard from './TopicCard' import TopicCard from './Views/TopicCard'
import Util from './Util' import Util from './Util'
import Visualize from './Visualize' import Visualize from './Visualize'
import clipboard from 'clipboard-js'
let panningInt let panningInt
@ -1418,9 +1422,7 @@ const JIT = {
<div class="expandLi"></div> <div class="expandLi"></div>
</li>` </li>`
const metacodeOptions = $('#metacodeOptions').html() menustring += '<li class="rc-metacode"><div class="rc-icon"></div>Change metacode<div id="metacodeOptionsWrapper"></div><div class="expandLi"></div></li>'
menustring += '<li class="rc-metacode"><div class="rc-icon"></div>Change metacode' + metacodeOptions + '<div class="expandLi"></div></li>'
} }
if (Active.Topic) { if (Active.Topic) {
if (!Active.Mapper) { if (!Active.Mapper) {
@ -1475,6 +1477,25 @@ const JIT = {
// add the menu to the page // add the menu to the page
$('#wrapper').append(rightclickmenu) $('#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 // attach events to clicks on the list items
// delete the selected things from the database // delete the selected things from the database
@ -1521,13 +1542,6 @@ const JIT = {
Control.updateSelectedPermissions($(this).text()) 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 // fetch relatives
let fetchSent = false let fetchSent = false
$('.rc-siblings').hover(function() { $('.rc-siblings').hover(function() {

View file

@ -1,6 +1,7 @@
/* global $ */ /* global $ */
import Active from './Active' import Active from './Active'
import Create from './Create'
import Control from './Control' import Control from './Control'
import DataModel from './DataModel' import DataModel from './DataModel'
import JIT from './JIT' import JIT from './JIT'
@ -31,11 +32,18 @@ const Listeners = {
JIT.escKeyHandler() JIT.escKeyHandler()
break break
case 46: // if DEL is pressed case 46: // if DEL is pressed
e.preventDefault() if(e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA" && (Selected.Nodes.length + Selected.Edges.length) > 0){
Control.deleteSelected() e.preventDefault()
Control.removeSelectedNodes()
Control.removeSelectedEdges()
}
break break
case 65: // if a or A is pressed 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 nodesCount = Object.keys(Visualize.mGraph.graph.nodes).length
const selectedNodesCount = Selected.Nodes.length const selectedNodesCount = Selected.Nodes.length
e.preventDefault() e.preventDefault()

View file

@ -16,7 +16,7 @@ import Realtime from '../Realtime'
import Router from '../Router' import Router from '../Router'
import Selected from '../Selected' import Selected from '../Selected'
import SynapseCard from '../SynapseCard' import SynapseCard from '../SynapseCard'
import TopicCard from '../TopicCard' import TopicCard from '../Views/TopicCard'
import Visualize from '../Visualize' import Visualize from '../Visualize'
import CheatSheet from './CheatSheet' import CheatSheet from './CheatSheet'

View file

@ -20,6 +20,10 @@ const PasteInput = {
}, false) }, false)
window.addEventListener('drop', function(e) { window.addEventListener('drop', function(e) {
e = e || window.event e = e || window.event
// prevent conflict with react-dropzone file uploader
if (event.target.id !== 'infovis-canvas') return
e.preventDefault() e.preventDefault()
var coords = Util.pixelsToCoords(Visualize.mGraph, { x: e.clientX, y: e.clientY }) var coords = Util.pixelsToCoords(Visualize.mGraph, { x: e.clientX, y: e.clientY })
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {

View file

@ -14,7 +14,7 @@ import Router from './Router'
import Selected from './Selected' import Selected from './Selected'
import Settings from './Settings' import Settings from './Settings'
import SynapseCard from './SynapseCard' import SynapseCard from './SynapseCard'
import TopicCard from './TopicCard' import TopicCard from './Views/TopicCard'
import Util from './Util' import Util from './Util'
import Visualize from './Visualize' import Visualize from './Visualize'

View file

@ -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('<div id="linkremove"></div>')
$('#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 = $('<a/>', {
id: 'embedlyLink',
'data-card-description': '0',
href: text
}).html(text)
$('.attachments').addClass('hidden')
$('.embeds').append(embedlyEl)
$('.embeds').append('<div id="embedlyLinkLoader"></div>')
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('<div class="expandMetacodeSelect"></div>')
.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('<div class="nameCounter forTopic"></div>')
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('<ul class="permissionSelect"><li class="public"></li><li class="private"></li></ul>')
} else if ($(this).hasClass('pu')) {
$(this).append('<ul class="permissionSelect"><li class="commons"></li><li class="private"></li></ul>')
} else if ($(this).hasClass('pr')) {
$(this).append('<ul class="permissionSelect"><li class="commons"></li><li class="public"></li></ul>')
}
$('.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 = '<a href="' + topic.get('link') + '" id="embedlyLink" target="_blank" data-card-description="0">'
nodeValues.embeds += topic.get('link')
nodeValues.embeds += '</a><div id="embedlyLinkLoader"></div>'
nodeValues.attachmentsHidden = 'hidden'
nodeValues.hasAttachment = 'hasAttachment'
} else {
nodeValues.embeds = ''
nodeValues.hasAttachment = ''
}
if (authorized) {
nodeValues.attachments = '<div class="addLink"><div id="addLinkIcon"></div>'
nodeValues.attachments += '<div id="addLinkInput"><input placeholder="Enter or paste a link"></input>'
nodeValues.attachments += '<div id="addLinkReset"></div></div></div>'
} 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 += '<li><a href="' + url + '">' + inmapsAr[i] + '</a></li>'
}
} else {
for (let i = 0; i < 5; i++) {
const url = '/maps/' + inmapsLinks[i]
nodeValues.inmaps += '<li><a href="' + url + '">' + inmapsAr[i] + '</a></li>'
}
const extra = inmapsAr.length - 5
nodeValues.inmaps += '<li><span class="showMore">See ' + extra + ' more...</span></li>'
for (let i = 5; i < inmapsAr.length; i++) {
const url = '/maps/' + inmapsLinks[i]
nodeValues.inmaps += '<li class="hideExtra extraText"><a href="' + url + '">' + inmapsAr[i] + '</a></li>'
}
}
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

View file

@ -1,6 +1,6 @@
/* global $ */ /* global $ */
import { Parser, HtmlRenderer } from 'commonmark' import { Parser, HtmlRenderer, Node } from 'commonmark'
import { emojiIndex } from 'emoji-mart' import { emojiIndex } from 'emoji-mart'
import { escapeRegExp } from 'lodash' import { escapeRegExp } from 'lodash'
@ -135,9 +135,26 @@ const Util = {
}, },
mdToHTML: text => { mdToHTML: text => {
const safeText = 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 // use safe: true to filter xss
return new HtmlRenderer({ safe: true }) return new HtmlRenderer({ safe: true }).render(parsed)
.render(new Parser().parse(safeText))
}, },
logCanvasAttributes: function(canvas) { logCanvasAttributes: function(canvas) {
const fakeMgraph = { canvas } const fakeMgraph = { canvas }
@ -181,6 +198,37 @@ const Util = {
}) })
} }
return text 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);
} }
} }

View file

@ -53,6 +53,20 @@ const ExploreMaps = {
url: `/maps/${map.id}/access_request` url: `/maps/${map.id}/access_request`
}) })
GlobalUI.notifyUser('You will be notified by email if request accepted') 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( ReactDOM.render(

View file

@ -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

View file

@ -4,18 +4,21 @@ import ExploreMaps from './ExploreMaps'
import ChatView from './ChatView' import ChatView from './ChatView'
import VideoView from './VideoView' import VideoView from './VideoView'
import Room from './Room' import Room from './Room'
import TopicCard from './TopicCard'
import { JUNTO_UPDATED } from '../Realtime/events' import { JUNTO_UPDATED } from '../Realtime/events'
const Views = { const Views = {
init: (serverData) => { init: (serverData) => {
$(document).on(JUNTO_UPDATED, () => ExploreMaps.render()) $(document).on(JUNTO_UPDATED, () => ExploreMaps.render())
ChatView.init([serverData['sounds/MM_sounds.mp3'], serverData['sounds/MM_sounds.ogg']]) ChatView.init([serverData['sounds/MM_sounds.mp3'], serverData['sounds/MM_sounds.ogg']])
TopicCard.init(serverData)
}, },
ExploreMaps, ExploreMaps,
ChatView, ChatView,
VideoView, VideoView,
Room Room,
TopicCard
} }
export { ExploreMaps, ChatView, VideoView, Room } export { ExploreMaps, ChatView, VideoView, Room, TopicCard }
export default Views export default Views

View file

@ -9,7 +9,7 @@ import DataModel from './DataModel'
import JIT from './JIT' import JIT from './JIT'
import Loading from './Loading' import Loading from './Loading'
import Router from './Router' import Router from './Router'
import TopicCard from './TopicCard' import TopicCard from './Views/TopicCard'
const Visualize = { const Visualize = {
mGraph: null, // a reference to the graph object. mGraph: null, // a reference to the graph object.

View file

@ -29,7 +29,6 @@ import Settings from './Settings'
import Synapse from './Synapse' import Synapse from './Synapse'
import SynapseCard from './SynapseCard' import SynapseCard from './SynapseCard'
import Topic from './Topic' import Topic from './Topic'
import TopicCard from './TopicCard'
import Util from './Util' import Util from './Util'
import Views from './Views' import Views from './Views'
import Visualize from './Visualize' import Visualize from './Visualize'
@ -71,7 +70,6 @@ Metamaps.Settings = Settings
Metamaps.Synapse = Synapse Metamaps.Synapse = Synapse
Metamaps.SynapseCard = SynapseCard Metamaps.SynapseCard = SynapseCard
Metamaps.Topic = Topic Metamaps.Topic = Topic
Metamaps.TopicCard = TopicCard
Metamaps.Util = Util Metamaps.Util = Util
Metamaps.Views = Views Metamaps.Views = Views
Metamaps.Visualize = Visualize Metamaps.Visualize = Visualize

View file

@ -2,18 +2,8 @@ import React, { PropTypes, Component } from 'react'
import Dropzone from 'react-dropzone' import Dropzone from 'react-dropzone'
class ImportDialogBox extends Component { class ImportDialogBox extends Component {
constructor(props) {
super(props)
this.state = {
}
}
handleExport = format => () => {
window.open(`${window.location.pathname}/export.${format}`, '_blank')
}
handleFile = (files, e) => { handleFile = (files, e) => {
e.preventDefault() // prevent it from triggering the default drag-drop handler
this.props.onFileAdded(files[0]) this.props.onFileAdded(files[0])
} }
@ -21,13 +11,13 @@ class ImportDialogBox extends Component {
return ( return (
<div className="import-dialog"> <div className="import-dialog">
<h3>EXPORT</h3> <h3>EXPORT</h3>
<div className="import-blue-button" onClick={this.handleExport('csv')}> <div className="export-csv import-blue-button" onClick={this.props.onExport('csv')}>
Export as CSV Export as CSV
</div> </div>
<div className="import-blue-button" onClick={this.handleExport('json')}> <div className="export-json import-blue-button" onClick={this.props.onExport('json')}>
Export as JSON Export as JSON
</div> </div>
<div className="import-blue-button" onClick={this.props.downloadScreenshot}> <div className="download-screenshot import-blue-button" onClick={this.props.downloadScreenshot}>
Download screenshot Download screenshot
</div> </div>
<h3>IMPORT</h3> <h3>IMPORT</h3>
@ -45,8 +35,8 @@ class ImportDialogBox extends Component {
ImportDialogBox.propTypes = { ImportDialogBox.propTypes = {
onFileAdded: PropTypes.func, onFileAdded: PropTypes.func,
exampleImageUrl: PropTypes.string, downloadScreenshot: PropTypes.func,
downloadScreenshot: PropTypes.func onExport: PropTypes.func
} }
export default ImportDialogBox export default ImportDialogBox

View file

@ -1,5 +1,6 @@
import React, { Component, PropTypes } from 'react' import React, { Component, PropTypes } from 'react'
import { find, values } from 'lodash' import { find, values } from 'lodash'
import Util from '../../Metamaps/Util'
const IN_CONVERSATION = 1 // shared with /realtime/reducer.js const IN_CONVERSATION = 1 // shared with /realtime/reducer.js
@ -23,7 +24,8 @@ class Menu extends Component {
} }
render = () => { 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' } const style = { display: this.state.open ? 'block' : 'none' }
return <div className='dropdownMenu'> return <div className='dropdownMenu'>
@ -35,6 +37,7 @@ class Menu extends Component {
<ul className='menuItems' style={ style }> <ul className='menuItems' style={ style }>
<li className='star' onClick={ () => { this.toggle() && onStar(map) }}>Star Map</li> <li className='star' onClick={ () => { this.toggle() && onStar(map) }}>Star Map</li>
{ !map.authorizeToEdit(currentUser) && <li className='request' onClick={ () => { this.toggle() && onRequest(map) }}>Request Access</li> } { !map.authorizeToEdit(currentUser) && <li className='request' onClick={ () => { this.toggle() && onRequest(map) }}>Request Access</li> }
{ Util.isTester(currentUser) && <li className='follow' onClick={ () => { this.toggle() && onFollow(map) }}>{isFollowing ? 'Unfollow' : 'Follow'}</li> }
</ul> </ul>
</div> </div>
} }
@ -43,7 +46,8 @@ Menu.propTypes = {
currentUser: PropTypes.object.isRequired, currentUser: PropTypes.object.isRequired,
map: PropTypes.object.isRequired, map: PropTypes.object.isRequired,
onStar: PropTypes.func.isRequired, onStar: PropTypes.func.isRequired,
onRequest: PropTypes.func.isRequired onRequest: PropTypes.func.isRequired,
onFollow: PropTypes.func.isRequired
} }
const Metadata = (props) => { const Metadata = (props) => {
@ -80,7 +84,7 @@ const checkAndWrapInA = (shouldWrap, classString, mapId, element) => {
class MapCard extends Component { class MapCard extends Component {
render = () => { 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 hasMap = (juntoState.liveMaps[map.id] && values(juntoState.liveMaps[map.id]).length) || null
const realtimeMap = juntoState.liveMaps[map.id] const realtimeMap = juntoState.liveMaps[map.id]
@ -131,7 +135,7 @@ class MapCard extends Component {
</div>) } </div>) }
{ !mobile && hasMapper && <div className='mapHasMapper'><MapperList mappers={ mapperList } /></div> } { !mobile && hasMapper && <div className='mapHasMapper'><MapperList mappers={ mapperList } /></div> }
{ !mobile && hasConversation && <div className='mapHasConversation'><MapperList mappers={ mapperList } /></div> } { !mobile && hasConversation && <div className='mapHasConversation'><MapperList mappers={ mapperList } /></div> }
{ !mobile && currentUser && <Menu currentUser={ currentUser } map={ map } onStar= { onStar } onRequest={ onRequest } /> } { !mobile && currentUser && <Menu currentUser={ currentUser } map={ map } onStar= { onStar } onRequest={ onRequest } onFollow={ onFollow } /> }
</div> </div>
</div>) } </div>) }
</div> </div>
@ -145,7 +149,8 @@ MapCard.propTypes = {
juntoState: PropTypes.object, juntoState: PropTypes.object,
currentUser: PropTypes.object, currentUser: PropTypes.object,
onStar: PropTypes.func.isRequired, onStar: PropTypes.func.isRequired,
onRequest: PropTypes.func.isRequired onRequest: PropTypes.func.isRequired,
onFollow: PropTypes.func.isRequired
} }
export default MapCard export default MapCard

View file

@ -46,7 +46,7 @@ class Maps extends Component {
} }
render = () => { 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 style = { width: this.state.mapsWidth + 'px' }
const mobile = document && document.body.clientWidth <= MOBILE_VIEW_BREAKPOINT const mobile = document && document.body.clientWidth <= MOBILE_VIEW_BREAKPOINT
@ -56,7 +56,7 @@ class Maps extends Component {
<div style={ style }> <div style={ style }>
{ user ? <MapperCard user={ user } /> : null } { user ? <MapperCard user={ user } /> : null }
{ currentUser && !user && !(pending && maps.length === 0) ? <div className="map newMap"><a href="/maps/new"><div className="newMapImage"></div><span>Create new map...</span></a></div> : null } { currentUser && !user && !(pending && maps.length === 0) ? <div className="map newMap"><a href="/maps/new"><div className="newMapImage"></div><span>Create new map...</span></a></div> : null }
{ maps.models.map(map => <MapCard key={ map.id } map={ map } mobile={ mobile } juntoState={ juntoState } currentUser={ currentUser } onStar={ onStar } onRequest={ onRequest } />) } { maps.models.map(map => <MapCard key={ map.id } map={ map } mobile={ mobile } juntoState={ juntoState } currentUser={ currentUser } onStar={ onStar } onRequest={ onRequest } onFollow={ onFollow } />) }
<div className='clearfloat'></div> <div className='clearfloat'></div>
</div> </div>
</div> </div>
@ -79,7 +79,8 @@ Maps.propTypes = {
loadMore: PropTypes.func, loadMore: PropTypes.func,
pending: PropTypes.bool.isRequired, pending: PropTypes.bool.isRequired,
onStar: PropTypes.func.isRequired, onStar: PropTypes.func.isRequired,
onRequest: PropTypes.func.isRequired onRequest: PropTypes.func.isRequired,
onFollow: PropTypes.func.isRequired
} }
export default Maps export default Maps

View file

@ -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 (
<div id="metacodeOptions">
<ul>
{this.props.metacodeSets.map(set => (
<li key={set.name}>
<span>{set.name}</span>
<div className="expandMetacodeSet"></div>
<ul>
{set.metacodes.map(m => (
<li key={m.id}
onClick={() => this.props.onMetacodeSelect(m.id)}
>
<img width="24" height="24" src={m.icon_path} alt={m.name} />
<div className="mSelectName">{m.name}</div>
<div className="clearfloat"></div>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
)
}
}
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

View file

@ -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 (
<div className="attachments">
<EmbedlyLink link={link} authorizedToEdit={authorizedToEdit} updateTopic={updateTopic} />
</div>
)
}
}
Attachments.propTypes = {
topic: PropTypes.object, // Backbone object
authorizedToEdit: PropTypes.bool,
updateTopic: PropTypes.func
}
export default Attachments

View file

@ -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 <span tabIndex="0"
className={this.makeClassString()}
onFocus={this.startEditing}
onClick={this.startEditing}
{...this.props.defaultProps}
/>
}
}
class Desc extends Component {
render = () => {
const descHTML = (!this.props.desc && this.props.authorizedToEdit)
? '<p>Click to add description...</p>'
: Util.mdToHTML(this.props.desc)
if (this.props.authorizedToEdit) {
return (
<div className="scroll">
<div className="desc">
<MdTextArea value={this.props.desc}
propName="desc"
change={this.props.onChange}
className="riek_desc"
classEditing="riek-editing"
editProps={{
onKeyPress: e => {
const ENTER = 13
if (!e.shiftKey && e.which === ENTER) {
e.preventDefault()
this.props.onChange({ desc: e.target.value })
}
}
}}
defaultProps={{
dangerouslySetInnerHTML: { __html: descHTML }
}}
/>
<div className="clearfloat"></div>
</div>
</div>
)
} else {
return (
<div className="scroll">
<div className="desc">
<span className="riek_desc">
{this.props.desc}
</span>
</div>
</div>
)
}
}
}
Desc.propTypes = {
desc: PropTypes.string, // markdown
authorizedToEdit: PropTypes.bool,
onChange: PropTypes.func
}
export default Desc

View file

@ -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 (
<div>
<a style={{ display: notReady ? 'none' : 'block' }}
href={link}
id="embedlyLink"
target="_blank"
data-card-description="0"
>
{link}
</a>
{notReady && <div id="embedlyLinkLoader">loading...</div>}
</div>
)
}
}
EmbedlyCard.propTypes = {
link: PropTypes.string
}
export default EmbedlyCard

View file

@ -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 (
<div className={hasAttachment ? 'embeds' : 'link-adder'}>
<div className="addLink"
style={{ display: hasAttachment ? 'none' : 'block' }}
>
<div id="addLinkIcon"></div>
<div id="addLinkInput">
<input ref={input => (this.linkInput = input)}
placeholder="Enter or paste a link"
value={linkEdit}
onChange={this.onLinkChangeHandler}
onKeyUp={this.onLinkKeyUpHandler}></input>
{linkEdit && <div id="addLinkReset" onClick={this.resetLink}></div>}
</div>
</div>
{link && <Card link={link} />}
{authorizedToEdit && (
<div id="linkremove"
style={{ display: hasAttachment ? 'block' : 'none' }}
onClick={this.removeLink}
/>
)}
</div>
)
}
}
EmbedlyLink.propTypes = {
link: PropTypes.string,
authorizedToEdit: PropTypes.bool,
updateTopic: PropTypes.func
}
export default EmbedlyLink

View file

@ -0,0 +1,17 @@
import React, { PropTypes, Component } from 'react'
class Follow extends Component {
render = () => {
const { isFollowing, onFollow } = this.props
return <div className='topicFollow' onClick={onFollow}>
{isFollowing ? 'Unfollow' : 'Follow'}
</div>
}
}
Follow.propTypes = {
isFollowing: PropTypes.bool,
onFollow: PropTypes.func
}
export default Follow

View file

@ -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(<li key={obj.mapId}><a href={`/maps/${obj.mapId}`}>{obj.mapName}</a></li>)
})
if (extraLinks.length > 0) {
if (this.state.showMoreMaps) {
extraLinks.forEach(obj => {
output.push(<li key={obj.mapId} className="hideExtra extraText"><a href={`/maps/${obj.mapId}`}>{obj.mapName}</a></li>)
})
}
const text = this.state.showMoreMaps ? 'See less...' : `See ${extraLinks.length} more...`
output.push(<li key="showMore"><span className="showMore" onClick={this.toggleShowMoreMaps}>{text}</span></li>)
}
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 (
<div className="links">
<div className="linkItem icon metacodeItem"
style={{ zIndex: this.state.showMetacodeTitle ? 4 : 1 }}
onMouseLeave={() => this.setState({ showMetacodeTitle: false, showMetacodeSelect: false })}
onClick={this.handleMetacodeBarClick}
>
<div className={`metacodeTitle mbg${metacode.get('id')}`}
style={{ display: this.state.showMetacodeTitle ? 'block' : 'none' }}
>
{metacode.get('name')}
<div className="expandMetacodeSelect"/>
</div>
<div className="metacodeImage"
style={{backgroundImage: `url(${metacode.get('icon')})`}}
title="click and drag to move card"
onMouseEnter={() => this.setState({ showMetacodeTitle: true })}
/>
<div className="metacodeSelect"
style={{ display: this.state.showMetacodeSelect ? 'block' : 'none' }}
>
<MetacodeSelect onMetacodeSelect={this.handleMetacodeSelect} metacodeSets={this.props.metacodeSets} />
</div>
</div>
<div className="linkItem contributor">
<a href={`/explore/mapper/${topic.get('user_id')}`} target="_blank"><img src={topic.get('user_image')} className="contributorIcon" width="32" height="32" /></a>
<div className="contributorName">{topic.get('user_name')}</div>
</div>
<div className="linkItem mapCount"
onMouseOver={this.updateState('hoveringMapCount', true)}
onMouseOut={this.updateState('hoveringMapCount', false)}
onClick={this.updateState('showInMaps', !this.state.showInMaps)}
>
<div className="mapCountIcon"></div>
{topic.get('map_count').toString()}
{!this.state.showInMaps && this.state.hoveringMapCount && (
<div className="hoverTip">Click to see which maps topic appears on</div>
)}
{this.state.showInMaps && <div className="tip"><ul>{this.inMaps(topic)}</ul></div>}
</div>
<a href={`/topics/${topic.id}`}
target="_blank"
className="linkItem synapseCount"
onMouseOver={this.updateState('hoveringSynapseCount', true)}
onMouseOut={this.updateState('hoveringSynapseCount', false)}
>
<div className="synapseCountIcon"></div>
{topic.get('synapse_count').toString()}
{this.state.hoveringSynapseCount && <div className="tip">Click to see this topics synapses</div>}
</a>
<Permission
permission={topic.get('permission')}
authorizedToEdit={authorizedPermissionChange}
updateTopic={this.props.updateTopic}
/>
<div className="clearfloat"></div>
</div>
)
}
}
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

View file

@ -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 (
<div className={classes}
title={permission}
onClick={authorizedToEdit ? this.togglePermissionSelect : null}
>
<ul className="permissionSelect"
style={{ display: selectingPermission ? 'block' : 'none' }}
>
{permission !== 'commons' && <li className='commons' onClick={this.liClick('commons')}></li>}
{permission !== 'public' && <li className='public' onClick={this.liClick('public')}></li>}
{permission !== 'private' && <li className='private' onClick={this.liClick('private')}></li>}
</ul>
</div>
)
}
}
Permission.propTypes = {
permission: PropTypes.string, // 'co', 'pu', or 'pr'
authorizedToEdit: PropTypes.bool,
updateTopic: PropTypes.func
}
export default onClickOutsideAddon(Permission)

View file

@ -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 (
<span className="title">
<RIETextArea value={this.props.name}
ref={textarea => { 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`
}
}}
/>
<span className="nameCounter" ref={span => { this.nameCounter = span }}>
{this.nameCounterText()}
</span>
</span>
)
} else {
return (
<span className="title">
<span className="titleWrapper">
{this.props.name}
</span>
</span>
)
}
}
}
Title.propTypes = {
name: PropTypes.string,
onChange: PropTypes.func,
authorizedToEdit: PropTypes.bool
}
export default Title

View file

@ -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 (
<div className={classname}>
<div className={`CardOnGraph ${hasAttachment ? 'hasAttachment' : ''}`} id={`topic_${topic.id}`}>
<Title name={topic.get('name')}
authorizedToEdit={authorizedToEdit}
onChange={this.props.updateTopic}
/>
<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

View file

@ -449,7 +449,7 @@ $.event = {
isRightClick: function(e) { isRightClick: function(e) {
return (e.which == 3 || e.button == 2); return (e.which == 3 || e.button == 2);
}, },
getPos: function(e, win) { getPos: function(e, win, touchIndex) {
// get mouse position // get mouse position
win = win || window; win = win || window;
e = e || win.event; e = e || win.event;
@ -457,7 +457,7 @@ $.event = {
doc = doc.documentElement || doc.body; doc = doc.documentElement || doc.body;
//TODO(nico): make touch event handling better //TODO(nico): make touch event handling better
if(e.touches && e.touches.length) { if(e.touches && e.touches.length) {
e = e.touches[0]; e = e.touches[touchIndex || 0];
} }
var page = { var page = {
x: e.pageX || (e.clientX + doc.scrollLeft), x: e.pageX || (e.clientX + doc.scrollLeft),
@ -2469,33 +2469,7 @@ Extras.Classes.Navigation = new Class({
// START METAMAPS CODE // START METAMAPS CODE
if (((ans > 1) && (5 >= this.canvas.scaleOffsetX)) || ((ans < 1) && (this.canvas.scaleOffsetX >= 0.2))) { if (((ans > 1) && (5 >= this.canvas.scaleOffsetX)) || ((ans < 1) && (this.canvas.scaleOffsetX >= 0.2))) {
var s = this.canvas.getSize(), Metamaps.Util.zoomOnPoint(this, ans, {x: e.pageX, y: e.pageY})
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);
} }
// END METAMAPS CODE // END METAMAPS CODE
@ -2620,109 +2594,132 @@ Extras.Classes.Navigation = new Class({
Metamaps.Mouse.changeInY = 0; Metamaps.Mouse.changeInY = 0;
if((this.config.panning == 'avoid nodes' && eventInfo.getNode()) || eventInfo.getEdge()) return; if((this.config.panning == 'avoid nodes' && eventInfo.getNode()) || eventInfo.getEdge()) return;
this.pressed = true; 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; Metamaps.Mouse.didPan = false;
this.pos = eventInfo.getPos();
var canvas = this.canvas, var canvas = this.canvas,
ox = canvas.translateOffsetX, ox = canvas.translateOffsetX,
oy = canvas.translateOffsetY, oy = canvas.translateOffsetY,
sx = canvas.scaleOffsetX, sx = canvas.scaleOffsetX,
sy = canvas.scaleOffsetY; sy = canvas.scaleOffsetY;
this.pos.x *= sx;
this.pos.x += ox; if (e.touches.length === 1) {
this.pos.y *= sy; this.pos = eventInfo.getPos();
this.pos.y += oy; } 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) { onTouchMove: function(e, win, eventInfo) {
e.preventDefault()
if(!this.config.panning) return; if(!this.config.panning) return;
if(!this.pressed) 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) { if (e.touches.length == 1) {
var rightClick = e.button == 2 || (navigator.platform.indexOf("Mac") != -1 && e.ctrlKey); currentPos = eventInfo.getPos()
if (!Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) { } else if (e.touches.length >= 2) {
Metamaps.Visualize.mGraph.busy = true; var s = canvas.getSize(),
Metamaps.boxStartCoordinates = eventInfo.getPos(); pos1 = $.event.getPos(e, win, 0),
return; pos2 = $.event.getPos(e, win, 1),
touch1 = {
x: (pos1.x - s.width/2 - ox) * 1/sx,
y: (pos1.y - s.height/2 - oy) * 1/sy
},
touch2 = {
x: (pos2.x - s.width/2 - ox) * 1/sx,
y: (pos2.y - s.height/2 - oy) * 1/sy
};
currentPos = {
x: (touch1.x + touch2.x) / 2,
y: (touch1.y + touch2.y) / 2
} }
if (Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) {
Metamaps.Visualize.mGraph.busy = true;
Metamaps.JIT.drawSelectBox(eventInfo,e);
return;
}
if (rightClick){
return;
}
if (e.target.id != 'infovis-canvas') {
this.pressed = false;
return;
}
Metamaps.Mouse.didPan = true;
var thispos = this.pos,
currentPos = eventInfo.getPos(),
canvas = this.canvas,
ox = canvas.translateOffsetX,
oy = canvas.translateOffsetY,
sx = canvas.scaleOffsetX,
sy = canvas.scaleOffsetY;
currentPos.x *= sx;
currentPos.y *= sy;
currentPos.x += ox;
currentPos.y += oy;
var x = currentPos.x - thispos.x,
y = currentPos.y - thispos.y;
Metamaps.Mouse.changeInX = x;
Metamaps.Mouse.changeInY = y;
this.pos = currentPos;
this.canvas.translate(x * 1/sx, y * 1/sy);
jQuery(document).trigger(Metamaps.JIT.events.pan);
} }
/* currentPos.x *= sx;
else if (e.touches.length == 2) { currentPos.y *= sy;
var touch1 = e.touches[0] currentPos.x += ox;
var touch2 = e.touches[1] currentPos.y += oy;
var canvas = this.canvas 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);
callCount++; if (e.touches.length >= 2) {
var currentPixelRadius = Metamaps.Util.getDistance({
var dist = Metamaps.Util.getDistance({ x: e.touches[0].clientX,
x: touch1.clientX, y: e.touches[0].clientY
y: touch1.clientY
}, { }, {
x: touch2.clientX, x: e.touches[1].clientX,
y: touch2.clientY y: e.touches[1].clientY
}) }) / 2
var desiredScale = currentPixelRadius / this.unitRadius
if (!this.initDist) { var scaler = desiredScale / sx
this.initDist = dist var midpoint = {
this.initScale = canvas.scaleOffsetX x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
} }
var scale = (dist / this.initDist) if (30 >= desiredScale && desiredScale >= 0.2) {
Metamaps.Util.zoomOnPoint(this, scaler, midpoint)
document.getElementById("header_content").innerHTML = scale + ' ' + canvas.scaleOffsetX jQuery(document).trigger(Metamaps.JIT.events.zoom)
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) { onTouchEnd: function(e, win, eventInfo, isRightClick) {
if(!this.config.panning) return; if(!this.config.panning) return;
this.pressed = false; if (e.touches.length === 1) {
if (Metamaps.Mouse.didPan) Metamaps.JIT.SmoothPanning(); var canvas = this.canvas,
this.initDist = false 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 // END METAMAPS CODE
}); });

View file

@ -113,9 +113,15 @@ describe('Metamaps.Util.js', function() {
expect(Util.mdToHTML(md).trim()).to.equal(html) expect(Util.mdToHTML(md).trim()).to.equal(html)
}) })
it('links and images', function() { it('links', function() {
const md = '[Link](https://metamaps.cc) ![Image](https://example.org/image.png)' const md = '[Link](https://metamaps.cc)'
const html = '<p><a href="https://metamaps.cc">Link</a> <img src="https://example.org/image.png" alt="Image" /></p>' 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) expect(Util.mdToHTML(md).trim()).to.equal(html)
}) })
}) })

View file

@ -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: () => {} })
})
})

View file

@ -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: {} }

20
lib/tasks/emails.rake Normal file
View file

@ -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

View file

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"build": "webpack", "build": "webpack",
"build:watch": "webpack --watch", "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": "eslint frontend",
"eslint:fix": "eslint --fix frontend" "eslint:fix": "eslint --fix frontend"
}, },
@ -35,7 +35,7 @@
"csv-parse": "1.1.10", "csv-parse": "1.1.10",
"emoji-mart": "0.3.7", "emoji-mart": "0.3.7",
"getscreenmedia": "2.0.0", "getscreenmedia": "2.0.0",
"hark": "git://github.com/otalk/hark#342ef9b7eff2", "hark": "1.1.5",
"howler": "2.0.2", "howler": "2.0.2",
"jquery": "3.1.1", "jquery": "3.1.1",
"json-loader": "0.5.4", "json-loader": "0.5.4",
@ -45,10 +45,12 @@
"react": "15.4.2", "react": "15.4.2",
"react-dom": "15.4.2", "react-dom": "15.4.2",
"react-dropzone": "3.9.1", "react-dropzone": "3.9.1",
"react-onclickoutside": "5.9.0",
"redux": "3.6.0", "redux": "3.6.0",
"riek": "1.0.7",
"simplewebrtc": "2.2.2", "simplewebrtc": "2.2.2",
"socket.io": "1.3.7", "socket.io": "1.3.7",
"webpack": "1.14.0" "webpack": "2.2.1"
}, },
"devDependencies": { "devDependencies": {
"babel-eslint": "^7.1.1", "babel-eslint": "^7.1.1",
@ -59,7 +61,10 @@
"eslint-plugin-promise": "^3.4.0", "eslint-plugin-promise": "^3.4.0",
"eslint-plugin-react": "^6.8.0", "eslint-plugin-react": "^6.8.0",
"eslint-plugin-standard": "^2.0.1", "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": { "optionalDependencies": {
"raml2html": "4.0.5" "raml2html": "4.0.5"

View file

@ -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

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe MapActivityMailer, type: :mailer do
end

View file

@ -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

View file

@ -6,12 +6,12 @@ class MapMailerPreview < ActionMailer::Preview
MapMailer.invite_to_edit(user_map) MapMailer.invite_to_edit(user_map)
end end
def access_request_email def access_request
request = AccessRequest.first request = AccessRequest.first
MapMailer.access_request(request) MapMailer.access_request(request)
end end
def access_approved_email def access_approved
request = AccessRequest.first request = AccessRequest.first
MapMailer.access_approved(request) MapMailer.access_approved(request)
end end

View file

@ -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

View file

@ -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

View file

@ -5,8 +5,12 @@ const NODE_ENV = process.env.NODE_ENV || 'development'
const plugins = [ const plugins = [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
"process.env.NODE_ENV": `"${NODE_ENV}"` "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') { if (NODE_ENV === 'production') {
plugins.push(new webpack.optimize.DedupePlugin()) plugins.push(new webpack.optimize.DedupePlugin())
plugins.push(new webpack.optimize.UglifyJsPlugin({ plugins.push(new webpack.optimize.UglifyJsPlugin({
@ -26,12 +30,13 @@ const devtool = NODE_ENV === 'production' ? undefined : 'cheap-module-eval-sourc
module.exports = { module.exports = {
context: __dirname, context: __dirname,
plugins, plugins,
externals,
devtool, devtool,
module: { module: {
preLoaders: [
{ test: /\.json$/, loader: 'json' }
],
loaders: [ loaders: [
{
test: /\.json$/, loader: 'json-loader'
},
{ {
test: /\.(js|jsx)?$/, test: /\.(js|jsx)?$/,
exclude: /node_modules/, exclude: /node_modules/,

5
webpack.test.config.js Normal file
View file

@ -0,0 +1,5 @@
const config = require('./webpack.config')
config.target = 'node'
module.exports = config