-
Open 'Synapse' card: Double-click on Synapse
+
Open Synapse card: Double-click on Synapse
Edit Synapse description: Click on current description text
Save Synapse description: Hit enter
Edit directionality: Select appropriate arrow boxes
Change synapse permission: Click on 'permission' icon (only for synapse creator)
-
Browse synapses / change visible synapse click on arrow icon and select desired synapse
+
Browse / select from multiple (stacked) synapses: Click dropdown icon and select desired synapse
Open 'Context Menu': Right-click/alt-click on Synapse
*Hide/Remove/Delete synapse within context menu
@@ -102,8 +103,10 @@
Move around Canvas: Click and drag
-
Zoom in/out: Scroll OR click on
&
-
Zoom to see all: Click
OR Ctrl + E
+
Zoom in/out: Scroll OR click on
&
+
Zoom to see all: Click
OR Ctrl + E
+
Filter Map Contents: Open the Filter Menu *** and toggle items off/on
+
Return to 'Explore Maps' (home) page: Click the Metamaps logo in the upper left corner
@@ -111,8 +114,8 @@
Select/Deselect Topic: Click on topic icon
Select/Deselect Synapse: Click on synapse
-
Select multiple Topics/Synapses: Shift + click
-
Make Selection box, select multiple Topics/Synapses: Right-click/Shift-click + drag on Canvas
+
Select multiple Topics/Synapses: Shift + click to include each
+
Select multiple with Selection Box: Right-click/Shift-click + drag on Canvas
Move all selected Topics & Synapses: Click + drag on selected topic(s)/synapse(s)
Open 'Context Menu': Right-click/Alt-click on selected topic(s)
*Hide/Remove/Delete/Change permissions of multiple topics & synapses within context menu
@@ -121,11 +124,10 @@
-
Open 'Search' prompt: Ctrl + /
-
Close 'Search' prompt: Esc
- <% if controller_name == "maps" && action_name == "show" %>
-
Add to current Map: Click "+" on a topic result
- <% end %>
+
Search for Topics and Maps: Type query terms into search bar, wait for results below
+
Limit search results: Click checkbox for only items you created; click arrow above Topics or Maps section to collapse
+
Add Topic to current Map: Click "+" on a topic result
+
Jump to Topic View: Click anywhere else on a topic result
Search by metacode: type "[name of metacode]:", then your search query. i.e. idea:create...
Search for map: type "map:", then your search query. i.e. map:exploring...
Search for mapper: type "mapper:", then your search query. i.e. mapper:Robert
diff --git a/app/views/shared/_mailer_unsubscribe_link.html.erb b/app/views/shared/_mailer_unsubscribe_link.html.erb
index 56730dd9..5aab4689 100644
--- a/app/views/shared/_mailer_unsubscribe_link.html.erb
+++ b/app/views/shared/_mailer_unsubscribe_link.html.erb
@@ -1,3 +1,3 @@
- <%= 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) %>
diff --git a/app/views/shared/_metacodeoptions.html.erb b/app/views/shared/_metacodeoptions.html.erb
index 54fb9e48..3cf9604e 100644
--- a/app/views/shared/_metacodeoptions.html.erb
+++ b/app/views/shared/_metacodeoptions.html.erb
@@ -3,61 +3,7 @@
# this code generates the list of icons that will drop down in the metacode select list on the topic card
#%>
-
+
diff --git a/app/views/shared/_switchmetacodes.html.erb b/app/views/shared/_switchmetacodes.html.erb
index b5607065..9dbdabb6 100644
--- a/app/views/shared/_switchmetacodes.html.erb
+++ b/app/views/shared/_switchmetacodes.html.erb
@@ -91,7 +91,9 @@
<% end %>
-
Choose Your Metacodes
+
Choose Your Metacodes
+
NONE
+
ALL
<% @list = '' %>
<% metacodesInUse = user_metacodes() %>
<% Metacode.order("name").all.each_with_index do |m, index| %>
@@ -116,4 +118,4 @@
\ No newline at end of file
+
diff --git a/app/views/topic_mailer/_unfollow.html.erb b/app/views/topic_mailer/_unfollow.html.erb
new file mode 100644
index 00000000..555722a9
--- /dev/null
+++ b/app/views/topic_mailer/_unfollow.html.erb
@@ -0,0 +1,3 @@
+
+You are receiving this email because you are following this topic.
+<%= link_to 'Unfollow', unfollow_from_email_topic_url(topic) %>
\ No newline at end of file
diff --git a/app/views/topic_mailer/_unfollow.text.erb b/app/views/topic_mailer/_unfollow.text.erb
new file mode 100644
index 00000000..78128bd4
--- /dev/null
+++ b/app/views/topic_mailer/_unfollow.text.erb
@@ -0,0 +1,2 @@
+You are receiving this email because you are following this topic.
+To unfollow, go to: <%= unfollow_from_email_topic_url(topic) %>
\ No newline at end of file
diff --git a/app/views/topic_mailer/added_to_map.html.erb b/app/views/topic_mailer/added_to_map.html.erb
index a2435272..5cdb4301 100644
--- a/app/views/topic_mailer/added_to_map.html.erb
+++ b/app/views/topic_mailer/added_to_map.html.erb
@@ -8,4 +8,6 @@
<%= link_to 'Go to Topic', topic_url(topic), style: button_style %>
-<%= link_to 'Go to Map', map_url(event.map), style: button_style %>
\ No newline at end of file
+<%= link_to 'Go to Map', map_url(event.map), style: button_style %>
+
+<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic } %>
\ No newline at end of file
diff --git a/app/views/topic_mailer/added_to_map.text.erb b/app/views/topic_mailer/added_to_map.text.erb
index 183d1e8e..67518cc6 100644
--- a/app/views/topic_mailer/added_to_map.text.erb
+++ b/app/views/topic_mailer/added_to_map.text.erb
@@ -3,4 +3,6 @@
<%= event.user.name %> added topic <%= topic.name %> to map <%= event.map.name %>
topic_url(topic)
-map_url(event.map)
\ No newline at end of file
+map_url(event.map)
+
+<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic } %>
\ No newline at end of file
diff --git a/app/views/topic_mailer/connected.html.erb b/app/views/topic_mailer/connected.html.erb
index 306e40c4..65e08c04 100644
--- a/app/views/topic_mailer/connected.html.erb
+++ b/app/views/topic_mailer/connected.html.erb
@@ -12,4 +12,6 @@
<% end %>
-<%= link_to 'View the connection', topic_url(topic1), style: button_style %>
\ No newline at end of file
+<%= link_to 'View the connection', topic_url(topic1), style: button_style %>
+
+<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic1 } %>
\ No newline at end of file
diff --git a/app/views/topic_mailer/connected.text.erb b/app/views/topic_mailer/connected.text.erb
index 33afd027..e3688025 100644
--- a/app/views/topic_mailer/connected.text.erb
+++ b/app/views/topic_mailer/connected.text.erb
@@ -5,4 +5,6 @@
<%= synapse.user.name %> connected topic <%= topic1.name %> to topic <%= topic2.name %>
<%= synapse.desc.length > 0 ? ' with the description' + synapse.desc : '' %>
-<%= topic_url(topic1) %>
\ No newline at end of file
+<%= topic_url(topic1) %>
+
+<%= render :partial => 'topic_mailer/unfollow', locals: { topic: topic1 } %>
\ No newline at end of file
diff --git a/config/initializers/mailboxer.rb b/config/initializers/mailboxer.rb
index b09caec2..b8abd079 100644
--- a/config/initializers/mailboxer.rb
+++ b/config/initializers/mailboxer.rb
@@ -15,8 +15,6 @@ MAP_ACCESS_REQUEST = 'ACCESS_REQUEST'
MAP_INVITE_TO_EDIT = 'INVITE_TO_EDIT'
# these ones are new
-# this one's a catch all for occurences on the map
-# MAP_ACTIVITY = 'MAP_ACTIVITY'
# MAP_RECEIVED_TOPIC
# MAP_LOST_TOPIC
# MAP_TOPIC_MOVED
diff --git a/config/routes.rb b/config/routes.rb
index f2850c81..80c0dbb4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -48,6 +48,9 @@ Metamaps::Application.routes.draw do
post :star, to: 'stars#create', default: { format: :json }
post :unstar, to: 'stars#destroy', default: { format: :json }
+ post :follow, default: { format: :json }
+ post :unfollow, default: { format: :json }
+ get :unfollow_from_email
end
end
@@ -83,6 +86,9 @@ Metamaps::Application.routes.draw do
get :network
get :relative_numbers
get :relatives
+ post :follow, default: { format: :json }
+ post :unfollow, default: { format: :json }
+ get :unfollow_from_email
end
collection do
get :autocomplete_topic
diff --git a/frontend/src/Metamaps/Create.js b/frontend/src/Metamaps/Create.js
index 466900e5..b01642c5 100644
--- a/frontend/src/Metamaps/Create.js
+++ b/frontend/src/Metamaps/Create.js
@@ -28,6 +28,8 @@ const Create = {
}).addClass('ui-tabs-vertical ui-helper-clearfix')
$('#metacodeSwitchTabs .ui-tabs-nav li').removeClass('ui-corner-top').addClass('ui-corner-left')
$('.customMetacodeList li').click(self.toggleMetacodeSelected) // within the custom metacode set tab
+ $('.selectAll').click(self.metacodeSelectorSelectAll)
+ $('.selectNone').click(self.metacodeSelectorSelectNone)
},
toggleMetacodeSelected: function() {
var self = Create
@@ -43,6 +45,46 @@ const Create = {
self.newSelectedMetacodes.push($(this).attr('id'))
self.newSelectedMetacodeNames.push($(this).attr('data-name'))
}
+ self.updateSelectAllColors()
+ },
+ updateSelectAllColors: function() {
+ $('.selectAll, .selectNone').removeClass('selected')
+ if (Create.metacodeSelectorAreAllSelected()) {
+ $('.selectAll').addClass('selected')
+ } else if (Create.metacodeSelectorAreNoneSelected()) {
+ $('.selectNone').addClass('selected')
+ }
+ },
+ metacodeSelectorSelectAll: function() {
+ $('.customMetacodeList li.toggledOff').each(Create.toggleMetacodeSelected)
+ Create.updateSelectAllColors()
+ },
+ metacodeSelectorSelectNone: function() {
+ $('.customMetacodeList li').not('.toggledOff').each(Create.toggleMetacodeSelected)
+ Create.updateSelectAllColors()
+ },
+ metacodeSelectorAreAllSelected: function() {
+ return $('.customMetacodeList li').toArray()
+ .map(li => !$(li).is('.toggledOff')) // note the ! on this line
+ .reduce((curr, prev) => curr && prev)
+ },
+ metacodeSelectorAreNoneSelected: function() {
+ return $('.customMetacodeList li').toArray()
+ .map(li => $(li).is('.toggledOff'))
+ .reduce((curr, prev) => curr && prev)
+ },
+ metacodeSelectorToggleSelectAll: function() {
+ // should be called when Create.isSwitchingSet is true and .customMetacodeList is visible
+ if (!Create.isSwitchingSet) return
+ if (!$('.customMetacodeList').is(':visible')) return
+
+ // If all are selected, then select none. Otherwise, select all.
+ if (Create.metacodeSelectorAreAllSelected()) {
+ Create.metacodeSelectorSelectNone()
+ } else {
+ // if some, but not all, are selected, it still runs this function
+ Create.metacodeSelectorSelectAll()
+ }
},
updateMetacodeSet: function(set, index, custom) {
if (custom && Create.newSelectedMetacodes.length === 0) {
@@ -114,7 +156,6 @@ const Create = {
}
})
},
-
cancelMetacodeSetSwitch: function() {
var self = Create
self.isSwitchingSet = false
diff --git a/frontend/src/Metamaps/DataModel/Map.js b/frontend/src/Metamaps/DataModel/Map.js
index 76c5eff3..b64f9f89 100644
--- a/frontend/src/Metamaps/DataModel/Map.js
+++ b/frontend/src/Metamaps/DataModel/Map.js
@@ -34,6 +34,9 @@ const Map = Backbone.Model.extend({
return false
}
},
+ isFollowedBy: function(mapper) {
+ return mapper.get('follows') && mapper.get('follows').maps.indexOf(this.get('id')) > -1
+ },
getUser: function() {
return Mapper.get(this.get('user_id'))
},
diff --git a/frontend/src/Metamaps/DataModel/Mapper.js b/frontend/src/Metamaps/DataModel/Mapper.js
index f772c288..dc5e5f0b 100644
--- a/frontend/src/Metamaps/DataModel/Mapper.js
+++ b/frontend/src/Metamaps/DataModel/Mapper.js
@@ -5,7 +5,7 @@ import outdent from 'outdent'
const Mapper = Backbone.Model.extend({
urlRoot: '/users',
- blacklist: ['created_at', 'updated_at'],
+ blacklist: ['created_at', 'updated_at', 'follows'],
toJSON: function(options) {
return _.omit(this.attributes, this.blacklist)
},
@@ -15,6 +15,20 @@ const Mapper = Backbone.Model.extend({
${this.get('name')}
`
+ },
+ followMap: function(id) {
+ this.get('follows').maps.push(id)
+ },
+ unfollowMap: function(id) {
+ const idIndex = this.get('follows').maps.indexOf(id)
+ if (idIndex > -1) this.get('follows').maps.splice(idIndex, 1)
+ },
+ followTopic: function(id) {
+ this.get('follows').topics.push(id)
+ },
+ unfollowTopic: function(id) {
+ const idIndex = this.get('follows').topics.indexOf(id)
+ if (idIndex > -1) this.get('follows').topics.splice(idIndex, 1)
}
})
diff --git a/frontend/src/Metamaps/DataModel/Topic.js b/frontend/src/Metamaps/DataModel/Topic.js
index a9e53b5d..e8025a7d 100644
--- a/frontend/src/Metamaps/DataModel/Topic.js
+++ b/frontend/src/Metamaps/DataModel/Topic.js
@@ -4,7 +4,7 @@ try { Backbone.$ = window.$ } catch (err) {}
import Active from '../Active'
import Filter from '../Filter'
-import TopicCard from '../TopicCard'
+import TopicCard from '../Views/TopicCard'
import Visualize from '../Visualize'
import DataModel from './index'
@@ -47,6 +47,9 @@ const Topic = Backbone.Model.extend({
if (mapper && this.get('user_id') === mapper.get('id')) return true
else return false
},
+ isFollowedBy: function(mapper) {
+ return mapper.get('follows') && mapper.get('follows').topics.indexOf(this.get('id')) > -1
+ },
getDate: function() {},
getMetacode: function() {
return DataModel.Metacodes.get(this.get('metacode_id'))
diff --git a/frontend/src/Metamaps/GlobalUI/ImportDialog.js b/frontend/src/Metamaps/GlobalUI/ImportDialog.js
index 1428ab6d..31913ea6 100644
--- a/frontend/src/Metamaps/GlobalUI/ImportDialog.js
+++ b/frontend/src/Metamaps/GlobalUI/ImportDialog.js
@@ -26,7 +26,10 @@ const ImportDialog = {
ReactDOM.render(React.createElement(ImportDialogBox, {
onFileAdded: PasteInput.handleFile,
exampleImageUrl: serverData['import-example.png'],
- downloadScreenshot: ImportDialog.downloadScreenshot
+ downloadScreenshot: ImportDialog.downloadScreenshot,
+ onExport: format => {
+ window.open(`${window.location.pathname}/export.${format}`, '_blank')
+ }
}), $('.importDialogWrapper').get(0))
},
show: function() {
diff --git a/frontend/src/Metamaps/Import.js b/frontend/src/Metamaps/Import.js
index a7aae5ae..9830eace 100644
--- a/frontend/src/Metamaps/Import.js
+++ b/frontend/src/Metamaps/Import.js
@@ -29,8 +29,8 @@ const Import = {
handleCSV: function(text, parserOpts = {}) {
const self = Import
- const topicsRegex = /("?Topics"?)([\s\S]*)/mi
- const synapsesRegex = /("?Synapses"?)([\s\S]*)/mi
+ const topicsRegex = /("?Topics"?[, \t"]*)([\s\S]*)/mi
+ const synapsesRegex = /("?Synapses"?[, \t"]*)([\s\S]*)/mi
let topicsText = text.match(topicsRegex) || ''
if (topicsText) topicsText = topicsText[2].replace(synapsesRegex, '')
let synapsesText = text.match(synapsesRegex) || ''
diff --git a/frontend/src/Metamaps/JIT.js b/frontend/src/Metamaps/JIT.js
index d91a2a93..bd952c21 100644
--- a/frontend/src/Metamaps/JIT.js
+++ b/frontend/src/Metamaps/JIT.js
@@ -2,9 +2,14 @@
import _ from 'lodash'
import outdent from 'outdent'
+import clipboard from 'clipboard-js'
+import React from 'react'
+import ReactDOM from 'react-dom'
import $jit from '../patched/JIT'
+import MetacodeSelect from '../components/MetacodeSelect'
+
import Active from './Active'
import Control from './Control'
import Create from './Create'
@@ -18,10 +23,9 @@ import Settings from './Settings'
import Synapse from './Synapse'
import SynapseCard from './SynapseCard'
import Topic from './Topic'
-import TopicCard from './TopicCard'
+import TopicCard from './Views/TopicCard'
import Util from './Util'
import Visualize from './Visualize'
-import clipboard from 'clipboard-js'
let panningInt
@@ -1418,9 +1422,7 @@ const JIT = {
`
- const metacodeOptions = $('#metacodeOptions').html()
-
- menustring += '
Change metacode' + metacodeOptions + ''
+ menustring += '
Change metacode'
}
if (Active.Topic) {
if (!Active.Mapper) {
@@ -1475,6 +1477,25 @@ const JIT = {
// add the menu to the page
$('#wrapper').append(rightclickmenu)
+ ReactDOM.render(
+ React.createElement(MetacodeSelect, {
+ onMetacodeSelect: metacodeId => {
+ if (Selected.Nodes.length > 1) {
+ // batch update multiple topics
+ Control.updateSelectedMetacodes(metacodeId)
+ } else {
+ const topic = DataModel.Topics.get(node.id)
+ topic.save({
+ metacode_id: metacodeId
+ })
+ }
+ $(rightclickmenu).remove()
+ },
+ metacodeSets: TopicCard.metacodeSets
+ }),
+ document.getElementById('metacodeOptionsWrapper')
+ )
+
// attach events to clicks on the list items
// delete the selected things from the database
@@ -1521,13 +1542,6 @@ const JIT = {
Control.updateSelectedPermissions($(this).text())
})
- // change the metacode of all the selected nodes that you have edit permission for
- $('.rc-metacode li li').click(function() {
- $('.rightclickmenu').remove()
- //
- Control.updateSelectedMetacodes($(this).attr('data-id'))
- })
-
// fetch relatives
let fetchSent = false
$('.rc-siblings').hover(function() {
diff --git a/frontend/src/Metamaps/Listeners.js b/frontend/src/Metamaps/Listeners.js
index c3b644df..d55abf92 100644
--- a/frontend/src/Metamaps/Listeners.js
+++ b/frontend/src/Metamaps/Listeners.js
@@ -1,6 +1,7 @@
/* global $ */
import Active from './Active'
+import Create from './Create'
import Control from './Control'
import DataModel from './DataModel'
import JIT from './JIT'
@@ -31,11 +32,18 @@ const Listeners = {
JIT.escKeyHandler()
break
case 46: // if DEL is pressed
- e.preventDefault()
- Control.deleteSelected()
+ if(e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA" && (Selected.Nodes.length + Selected.Edges.length) > 0){
+ e.preventDefault()
+ Control.removeSelectedNodes()
+ Control.removeSelectedEdges()
+ }
break
case 65: // if a or A is pressed
- if ((e.ctrlKey || e.metaKey) && onCanvas) {
+ if (Create.isSwitchingSet && e.ctrlKey || e.metaKey) {
+ Create.metacodeSelectorToggleSelectAll()
+ e.preventDefault()
+ break
+ } else if ((e.ctrlKey || e.metaKey) && onCanvas) {
const nodesCount = Object.keys(Visualize.mGraph.graph.nodes).length
const selectedNodesCount = Selected.Nodes.length
e.preventDefault()
diff --git a/frontend/src/Metamaps/Map/index.js b/frontend/src/Metamaps/Map/index.js
index 3df5a3e2..0b36a68d 100644
--- a/frontend/src/Metamaps/Map/index.js
+++ b/frontend/src/Metamaps/Map/index.js
@@ -16,7 +16,7 @@ import Realtime from '../Realtime'
import Router from '../Router'
import Selected from '../Selected'
import SynapseCard from '../SynapseCard'
-import TopicCard from '../TopicCard'
+import TopicCard from '../Views/TopicCard'
import Visualize from '../Visualize'
import CheatSheet from './CheatSheet'
diff --git a/frontend/src/Metamaps/PasteInput.js b/frontend/src/Metamaps/PasteInput.js
index b72ae545..03a92f86 100644
--- a/frontend/src/Metamaps/PasteInput.js
+++ b/frontend/src/Metamaps/PasteInput.js
@@ -20,6 +20,10 @@ const PasteInput = {
}, false)
window.addEventListener('drop', function(e) {
e = e || window.event
+
+ // prevent conflict with react-dropzone file uploader
+ if (event.target.id !== 'infovis-canvas') return
+
e.preventDefault()
var coords = Util.pixelsToCoords(Visualize.mGraph, { x: e.clientX, y: e.clientY })
if (e.dataTransfer.files.length > 0) {
diff --git a/frontend/src/Metamaps/Topic.js b/frontend/src/Metamaps/Topic.js
index 7cdcf3a7..b16da5da 100644
--- a/frontend/src/Metamaps/Topic.js
+++ b/frontend/src/Metamaps/Topic.js
@@ -14,7 +14,7 @@ import Router from './Router'
import Selected from './Selected'
import Settings from './Settings'
import SynapseCard from './SynapseCard'
-import TopicCard from './TopicCard'
+import TopicCard from './Views/TopicCard'
import Util from './Util'
import Visualize from './Visualize'
diff --git a/frontend/src/Metamaps/TopicCard.js b/frontend/src/Metamaps/TopicCard.js
deleted file mode 100644
index 3fa9a999..00000000
--- a/frontend/src/Metamaps/TopicCard.js
+++ /dev/null
@@ -1,474 +0,0 @@
-/* global $, CanvasLoader, Countable, Hogan, embedly */
-
-import Active from './Active'
-import DataModel from './DataModel'
-import GlobalUI from './GlobalUI'
-import Mapper from './Mapper'
-import Router from './Router'
-import Util from './Util'
-import Visualize from './Visualize'
-
-const TopicCard = {
- openTopicCard: null, // stores the topic that's currently open
- authorizedToEdit: false, // stores boolean for edit permission for open topic card
- RAILS_ENV: undefined,
- init: function(serverData) {
- var self = TopicCard
-
- if (serverData.RAILS_ENV) {
- self.RAILS_ENV = serverData.RAILS_ENV
- } else {
- console.error('RAILS_ENV is not defined! See TopicCard.js init function.')
- }
-
- // initialize best_in_place editing
- $('.authenticated div.permission.canEdit .best_in_place').best_in_place()
-
- TopicCard.generateShowcardHTML = Hogan.compile($('#topicCardTemplate').html())
-
- // initialize topic card draggability and resizability
- $('.showcard').draggable({
- handle: '.metacodeImage',
- stop: function() {
- $(this).height('auto')
- }
- })
-
- embedly('on', 'card.rendered', self.embedlyCardRendered)
- },
- /**
- * Will open the Topic Card for the node that it's passed
- * @param {$jit.Graph.Node} node
- */
- showCard: function(node, opts) {
- var self = TopicCard
- if (!opts) opts = {}
- var topic = node.getData('topic')
-
- self.openTopicCard = topic
- self.authorizedToEdit = topic.authorizeToEdit(Active.Mapper)
- // populate the card that's about to show with the right topics data
- self.populateShowCard(topic)
- return $('.showcard').fadeIn('fast', function() {
- if (opts.complete) {
- opts.complete()
- }
- })
- },
- hideCard: function() {
- var self = TopicCard
-
- $('.showcard').fadeOut('fast')
- self.openTopicCard = null
- self.authorizedToEdit = false
- },
- embedlyCardRendered: function(iframe) {
- $('#embedlyLinkLoader').hide()
-
- // means that the embedly call returned 404 not found
- if ($('#embedlyLink')[0]) {
- $('#embedlyLink').css('display', 'block').fadeIn('fast')
- $('.embeds').addClass('nonEmbedlyLink')
- }
-
- $('.CardOnGraph').addClass('hasAttachment')
- },
- showLinkRemover: function() {
- if (TopicCard.authorizedToEdit && $('#linkremove').length === 0) {
- $('.embeds').append('
')
- $('#linkremove').click(TopicCard.removeLink)
- }
- },
- removeLink: function() {
- var self = TopicCard
- self.openTopicCard.save({
- link: null
- })
- $('.embeds').empty().removeClass('nonEmbedlyLink')
- $('#addLinkInput input').val('')
- $('.attachments').removeClass('hidden')
- $('.CardOnGraph').removeClass('hasAttachment')
- },
- showLinkLoader: function() {
- var loader = new CanvasLoader('embedlyLinkLoader')
- loader.setColor('#4fb5c0') // default is '#000000'
- loader.setDiameter(28) // default is 40
- loader.setDensity(41) // default is 40
- loader.setRange(0.9) // default is 1.3
- loader.show() // Hidden by default
- },
- showLink: function(topic) {
- var e = embedly('card', document.getElementById('embedlyLink'))
- if (!e && TopicCard.RAILS_ENV !== 'development') {
- TopicCard.handleInvalidLink()
- } else if (!e) {
- $('#embedlyLink').attr('target', '_blank').html(topic.get('link')).show()
- $('#embedlyLinkLoader').hide()
- }
- },
- bindShowCardListeners: function(topic) {
- var self = TopicCard
- var showCard = document.getElementById('showcard')
-
- var authorized = self.authorizedToEdit
-
- // get mapper image
- var setMapperImage = function(mapper) {
- $('.contributorIcon').attr('src', mapper.get('image'))
- }
- Mapper.get(topic.get('user_id'), setMapperImage)
-
- // starting embed.ly
- var resetFunc = function() {
- $('#addLinkInput input').val('')
- $('#addLinkInput input').focus()
- }
- var inputEmbedFunc = function(event) {
- var element = this
- setTimeout(function() {
- var text = $(element).val()
- if (event.type === 'paste' || (event.type === 'keyup' && event.which === 13)) {
- // TODO evaluate converting this to '//' no matter what (infer protocol)
- if (text.slice(0, 7) !== 'http://' &&
- text.slice(0, 8) !== 'https://' &&
- text.slice(0, 2) !== '//') {
- text = '//' + text
- }
- topic.save({
- link: text
- })
- var embedlyEl = $('
', {
- id: 'embedlyLink',
- 'data-card-description': '0',
- href: text
- }).html(text)
- $('.attachments').addClass('hidden')
- $('.embeds').append(embedlyEl)
- $('.embeds').append('
')
-
- self.showLinkLoader()
- self.showLink(topic)
- }
- }, 100)
- }
- $('#addLinkReset').click(resetFunc)
- $('#addLinkInput input').bind('paste keyup', inputEmbedFunc)
-
- // initialize the link card, if there is a link
- if (topic.get('link') && topic.get('link') !== '') {
- self.showLinkLoader()
- self.showLink(topic)
- self.showLinkRemover()
- }
-
- var selectingMetacode = false
- // attach the listener that shows the metacode title when you hover over the image
- $('.showcard .metacodeImage').mouseenter(function() {
- $('.showcard .icon').css('z-index', '4')
- $('.showcard .metacodeTitle').show()
- })
- $('.showcard .linkItem.icon').mouseleave(function() {
- if (!selectingMetacode) {
- $('.showcard .metacodeTitle').hide()
- $('.showcard .icon').css('z-index', '1')
- }
- })
-
- var metacodeLiClick = function() {
- selectingMetacode = false
- var metacodeId = parseInt($(this).attr('data-id'))
- var metacode = DataModel.Metacodes.get(metacodeId)
- $('.CardOnGraph').find('.metacodeTitle').html(metacode.get('name'))
- .append('
')
- .attr('class', 'metacodeTitle mbg' + metacode.id)
- $('.CardOnGraph').find('.metacodeImage').css('background-image', 'url(' + metacode.get('icon') + ')')
- topic.save({
- metacode_id: metacode.id
- })
- Visualize.mGraph.plot()
- $('.metacodeSelect').hide().removeClass('onRightEdge onBottomEdge')
- $('.metacodeTitle').hide()
- $('.showcard .icon').css('z-index', '1')
- }
-
- var openMetacodeSelect = function(event) {
- var TOPICCARD_WIDTH = 300
- var METACODESELECT_WIDTH = 404
- var MAX_METACODELIST_HEIGHT = 270
-
- if (!selectingMetacode) {
- selectingMetacode = true
-
- // this is to make sure the metacode
- // select is accessible onscreen, when opened
- // while topic card is close to the right
- // edge of the screen
- var windowWidth = $(window).width()
- var showcardLeft = parseInt($('.showcard').css('left'))
- var distanceFromEdge = windowWidth - (showcardLeft + TOPICCARD_WIDTH)
- if (distanceFromEdge < METACODESELECT_WIDTH) {
- $('.metacodeSelect').addClass('onRightEdge')
- }
-
- // this is to make sure the metacode
- // select is accessible onscreen, when opened
- // while topic card is close to the bottom
- // edge of the screen
- var windowHeight = $(window).height()
- var showcardTop = parseInt($('.showcard').css('top'))
- var topicTitleHeight = $('.showcard .title').height() + parseInt($('.showcard .title').css('padding-top')) + parseInt($('.showcard .title').css('padding-bottom'))
- var distanceFromBottom = windowHeight - (showcardTop + topicTitleHeight)
- if (distanceFromBottom < MAX_METACODELIST_HEIGHT) {
- $('.metacodeSelect').addClass('onBottomEdge')
- }
-
- $('.metacodeSelect').show()
- event.stopPropagation()
- }
- }
-
- var hideMetacodeSelect = function() {
- selectingMetacode = false
- $('.metacodeSelect').hide().removeClass('onRightEdge onBottomEdge')
- $('.metacodeTitle').hide()
- $('.showcard .icon').css('z-index', '1')
- }
-
- if (authorized) {
- $('.showcard .metacodeTitle').click(openMetacodeSelect)
- $('.showcard').click(hideMetacodeSelect)
- $('.metacodeSelect > ul > li').click(function(event) {
- event.stopPropagation()
- })
- $('.metacodeSelect li li').click(metacodeLiClick)
-
- var bipName = $(showCard).find('.best_in_place_name')
- bipName.bind('best_in_place:activate', function() {
- var $el = bipName.find('textarea')
- var el = $el[0]
-
- $el.attr('maxlength', '140')
-
- $('.showcard .title').append('
')
-
- var callback = function(data) {
- $('.nameCounter.forTopic').html(data.all + '/140')
- }
- Countable.live(el, callback)
- })
- bipName.bind('best_in_place:deactivate', function() {
- $('.nameCounter.forTopic').remove()
- })
- bipName.keypress(function(e) {
- const ENTER = 13
- if (e.which === ENTER) { // enter
- $(this).data('bestInPlaceEditor').update()
- }
- })
-
- // bind best_in_place ajax callbacks
- bipName.bind('ajax:success', function() {
- var name = Util.decodeEntities($(this).html())
- topic.set('name', name)
- topic.trigger('saved')
- })
-
- // this is for all subsequent renders after in-place editing the desc field
- const bipDesc = $(showCard).find('.best_in_place_desc')
- bipDesc.bind('ajax:success', function() {
- var desc = $(this).html() === $(this).data('bip-nil')
- ? ''
- : $(this).text()
- topic.set('desc', desc)
- $(this).data('bip-value', desc)
- this.innerHTML = Util.mdToHTML(desc)
- topic.trigger('saved')
- })
- bipDesc.keypress(function(e) {
- // allow typing Enter with Shift+Enter
- const ENTER = 13
- if (e.shiftKey === false && e.which === ENTER) {
- $(this).data('bestInPlaceEditor').update()
- }
- })
- }
-
- var permissionLiClick = function(event) {
- selectingPermission = false
- var permission = $(this).attr('class')
- topic.save({
- permission: permission,
- defer_to_map_id: null
- })
- $('.showcard .mapPerm').removeClass('co pu pr minimize').addClass(permission.substring(0, 2))
- $('.showcard .permissionSelect').remove()
- event.stopPropagation()
- }
-
- var openPermissionSelect = function(event) {
- if (!selectingPermission) {
- selectingPermission = true
- $(this).addClass('minimize') // this line flips the drop down arrow to a pull up arrow
- if ($(this).hasClass('co')) {
- $(this).append('
')
- } else if ($(this).hasClass('pu')) {
- $(this).append('
')
- } else if ($(this).hasClass('pr')) {
- $(this).append('
')
- }
- $('.showcard .permissionSelect li').click(permissionLiClick)
- event.stopPropagation()
- }
- }
-
- var hidePermissionSelect = function() {
- selectingPermission = false
- $('.showcard .yourTopic .mapPerm').removeClass('minimize') // this line flips the pull up arrow to a drop down arrow
- $('.showcard .permissionSelect').remove()
- }
- // ability to change permission
- var selectingPermission = false
- if (topic.authorizePermissionChange(Active.Mapper)) {
- $('.showcard .yourTopic .mapPerm').click(openPermissionSelect)
- $('.showcard').click(hidePermissionSelect)
- }
-
- $('.links .mapCount').unbind().click(function(event) {
- $('.mapCount .tip').toggle()
- $('.showcard .hoverTip').toggleClass('hide')
- event.stopPropagation()
- })
- $('.mapCount .tip').unbind().click(function(event) {
- event.stopPropagation()
- })
- $('.showcard').unbind('.hideTip').bind('click.hideTip', function() {
- $('.mapCount .tip').hide()
- $('.showcard .hoverTip').removeClass('hide')
- })
-
- $('.mapCount .tip li a').click(Router.intercept)
-
- var originalText = $('.showMore').html()
- $('.mapCount .tip .showMore').unbind().toggle(
- function(event) {
- $('.extraText').toggleClass('hideExtra')
- $('.showMore').html('Show less...')
- },
- function(event) {
- $('.extraText').toggleClass('hideExtra')
- $('.showMore').html(originalText)
- })
-
- $('.mapCount .tip showMore').unbind().click(function(event) {
- event.stopPropagation()
- })
- },
- handleInvalidLink: function() {
- var self = TopicCard
-
- self.removeLink()
- GlobalUI.notifyUser('Invalid link')
- },
- populateShowCard: function(topic) {
- var self = TopicCard
-
- var showCard = document.getElementById('showcard')
-
- $(showCard).find('.permission').remove()
-
- var topicForTemplate = self.buildObject(topic)
- var html = self.generateShowcardHTML.render(topicForTemplate)
-
- if (topic.authorizeToEdit(Active.Mapper)) {
- let perm = document.createElement('div')
-
- var string = 'permission canEdit'
- if (topic.authorizePermissionChange(Active.Mapper)) string += ' yourTopic'
- perm.className = string
- perm.innerHTML = html
- showCard.appendChild(perm)
- } else {
- let perm = document.createElement('div')
- perm.className = 'permission cannotEdit'
- perm.innerHTML = html
- showCard.appendChild(perm)
- }
-
- TopicCard.bindShowCardListeners(topic)
- },
- generateShowcardHTML: null, // will be initialized into a Hogan template within init function
- // generateShowcardHTML
- buildObject: function(topic) {
- var nodeValues = {}
-
- var authorized = topic.authorizeToEdit(Active.Mapper)
-
- if (!authorized) {
- } else {
- }
-
- nodeValues.attachmentsHidden = ''
- if (topic.get('link') && topic.get('link') !== '') {
- nodeValues.embeds = '
'
- nodeValues.embeds += topic.get('link')
- nodeValues.embeds += ''
- nodeValues.attachmentsHidden = 'hidden'
- nodeValues.hasAttachment = 'hasAttachment'
- } else {
- nodeValues.embeds = ''
- nodeValues.hasAttachment = ''
- }
-
- if (authorized) {
- nodeValues.attachments = '
'
- nodeValues.attachments += '
'
- } else {
- nodeValues.attachmentsHidden = 'hidden'
- nodeValues.attachments = ''
- }
-
- var inmapsAr = topic.get('inmaps') || []
- var inmapsLinks = topic.get('inmapsLinks') || []
- nodeValues.inmaps = ''
- if (inmapsAr.length < 6) {
- for (let i = 0; i < inmapsAr.length; i++) {
- const url = '/maps/' + inmapsLinks[i]
- nodeValues.inmaps += '
' + inmapsAr[i] + ''
- }
- } else {
- for (let i = 0; i < 5; i++) {
- const url = '/maps/' + inmapsLinks[i]
- nodeValues.inmaps += '
' + inmapsAr[i] + ''
- }
- const extra = inmapsAr.length - 5
- nodeValues.inmaps += '
See ' + extra + ' more...'
- for (let i = 5; i < inmapsAr.length; i++) {
- const url = '/maps/' + inmapsLinks[i]
- nodeValues.inmaps += ''
- }
- }
- nodeValues.permission = topic.get('permission')
- nodeValues.mk_permission = topic.get('permission').substring(0, 2)
- nodeValues.map_count = topic.get('map_count').toString()
- nodeValues.synapse_count = topic.get('synapse_count').toString()
- nodeValues.id = topic.isNew() ? topic.cid : topic.id
- nodeValues.metacode = topic.getMetacode().get('name')
- nodeValues.metacode_class = 'mbg' + topic.get('metacode_id')
- nodeValues.imgsrc = topic.getMetacode().get('icon')
- nodeValues.name = topic.get('name')
- nodeValues.userid = topic.get('user_id')
- nodeValues.username = topic.get('user_name')
- nodeValues.date = topic.getDate()
- // the code for this is stored in /views/main/_metacodeOptions.html.erb
- nodeValues.metacode_select = $('#metacodeOptions').html()
- nodeValues.desc_nil = 'Click to add description...'
- nodeValues.desc_markdown = (topic.get('desc') === '' && authorized)
- ? nodeValues.desc_nil
- : topic.get('desc')
- nodeValues.desc_html = Util.mdToHTML(nodeValues.desc_markdown)
- return nodeValues
- }
-}
-
-export default TopicCard
diff --git a/frontend/src/Metamaps/Util.js b/frontend/src/Metamaps/Util.js
index 562843e7..e371e4e0 100644
--- a/frontend/src/Metamaps/Util.js
+++ b/frontend/src/Metamaps/Util.js
@@ -1,6 +1,6 @@
/* global $ */
-import { Parser, HtmlRenderer } from 'commonmark'
+import { Parser, HtmlRenderer, Node } from 'commonmark'
import { emojiIndex } from 'emoji-mart'
import { escapeRegExp } from 'lodash'
@@ -135,9 +135,26 @@ const Util = {
},
mdToHTML: text => {
const safeText = text || ''
+ const parsed = new Parser().parse(safeText)
+
+ // remove images to avoid http content in https context
+ const walker = parsed.walker()
+ for (let event = walker.next(); event = walker.next(); event) {
+ const node = event.node
+ if (node.type === 'image') {
+ const imageAlt = node.firstChild.literal
+ const imageSrc = node.destination
+ const textNode = new Node('text', node.sourcepos)
+ textNode.literal = ``
+
+ node.insertBefore(textNode)
+ node.unlink() // remove the image, replacing it with markdown
+ walker.resumeAt(textNode, false)
+ }
+ }
+
// use safe: true to filter xss
- return new HtmlRenderer({ safe: true })
- .render(new Parser().parse(safeText))
+ return new HtmlRenderer({ safe: true }).render(parsed)
},
logCanvasAttributes: function(canvas) {
const fakeMgraph = { canvas }
@@ -181,6 +198,37 @@ const Util = {
})
}
return text
+ },
+ isTester: function(currentUser) {
+ return ['connorturland@gmail.com', 'devin@callysto.com', 'chessscholar@gmail.com', 'solaureum@gmail.com', 'ishanshapiro@gmail.com'].indexOf(currentUser.get('email')) > -1
+ },
+ zoomOnPoint: function(graph, ans, zoomPoint) {
+ var s = graph.canvas.getSize(),
+ p = graph.canvas.getPos(),
+ ox = graph.canvas.translateOffsetX,
+ oy = graph.canvas.translateOffsetY,
+ sx = graph.canvas.scaleOffsetX,
+ sy = graph.canvas.scaleOffsetY;
+
+ var pointerCoordX = (zoomPoint.x - p.x - s.width / 2 - ox) * (1 / sx),
+ pointerCoordY = (zoomPoint.y - p.y - s.height / 2 - oy) * (1 / sy);
+
+ //This translates the canvas to be centred over the zoomPoint, then the canvas is zoomed as intended.
+ graph.canvas.translate(-pointerCoordX,-pointerCoordY);
+ graph.canvas.scale(ans, ans);
+
+ //Get the canvas attributes again now that is has changed
+ s = graph.canvas.getSize(),
+ p = graph.canvas.getPos(),
+ ox = graph.canvas.translateOffsetX,
+ oy = graph.canvas.translateOffsetY,
+ sx = graph.canvas.scaleOffsetX,
+ sy = graph.canvas.scaleOffsetY;
+ var newX = (zoomPoint.x - p.x - s.width / 2 - ox) * (1 / sx),
+ newY = (zoomPoint.y - p.y - s.height / 2 - oy) * (1 / sy);
+
+ //Translate the canvas to put the pointer back over top the same coordinate it was over before
+ graph.canvas.translate(newX-pointerCoordX,newY-pointerCoordY);
}
}
diff --git a/frontend/src/Metamaps/Views/ExploreMaps.js b/frontend/src/Metamaps/Views/ExploreMaps.js
index 0b7d841a..5c10691b 100644
--- a/frontend/src/Metamaps/Views/ExploreMaps.js
+++ b/frontend/src/Metamaps/Views/ExploreMaps.js
@@ -53,6 +53,20 @@ const ExploreMaps = {
url: `/maps/${map.id}/access_request`
})
GlobalUI.notifyUser('You will be notified by email if request accepted')
+ },
+ onFollow: function(map) {
+ const isFollowing = map.isFollowedBy(Active.Mapper)
+ $.post({
+ url: `/maps/${map.id}/${isFollowing ? 'un' : ''}follow`
+ })
+ if (isFollowing) {
+ GlobalUI.notifyUser('You are no longer following this map')
+ Active.Mapper.unfollowMap(map.id)
+ } else {
+ GlobalUI.notifyUser('You are now following this map')
+ Active.Mapper.followMap(map.id)
+ }
+ self.render()
}
}
ReactDOM.render(
diff --git a/frontend/src/Metamaps/Views/TopicCard.js b/frontend/src/Metamaps/Views/TopicCard.js
new file mode 100644
index 00000000..0b02fccd
--- /dev/null
+++ b/frontend/src/Metamaps/Views/TopicCard.js
@@ -0,0 +1,74 @@
+/* global $ */
+
+import React from 'react'
+import ReactDOM from 'react-dom'
+
+import Active from '../Active'
+import Visualize from '../Visualize'
+import GlobalUI from '../GlobalUI'
+
+import ReactTopicCard from '../../components/TopicCard'
+
+const TopicCard = {
+ openTopicCard: null, // stores the topic that's currently open
+ metacodeSets: [],
+ init: function(serverData) {
+ const self = TopicCard
+ self.metacodeSets = serverData.metacodeSets
+ },
+ populateShowCard: function(topic) {
+ const self = TopicCard
+ ReactDOM.render(
+ React.createElement(ReactTopicCard, {
+ topic: topic,
+ ActiveMapper: Active.Mapper,
+ updateTopic: obj => {
+ topic.save(obj, { success: topic => self.populateShowCard(topic) })
+ },
+ onFollow: () => {
+ const isFollowing = topic.isFollowedBy(Active.Mapper)
+ $.post({
+ url: `/topics/${topic.id}/${isFollowing ? 'un' : ''}follow`
+ })
+ if (isFollowing) {
+ GlobalUI.notifyUser('You are no longer following this topic')
+ Active.Mapper.unfollowTopic(topic.id)
+ } else {
+ GlobalUI.notifyUser('You are now following this topic')
+ Active.Mapper.followTopic(topic.id)
+ }
+ self.populateShowCard(topic)
+ },
+ metacodeSets: self.metacodeSets,
+ redrawCanvas: () => {
+ Visualize.mGraph.plot()
+ }
+ }),
+ document.getElementById('showcard')
+ )
+
+ // initialize draggability
+ $('.showcard').draggable({
+ handle: '.metacodeImage',
+ stop: function() {
+ $(this).height('auto')
+ }
+ })
+ },
+ showCard: function(node, opts) {
+ var self = TopicCard
+ if (!opts) opts = {}
+ var topic = node.getData('topic')
+ self.openTopicCard = topic
+ // populate the card that's about to show with the right topics data
+ self.populateShowCard(topic)
+ return $('.showcard').fadeIn('fast', () => opts.complete && opts.complete())
+ },
+ hideCard: function() {
+ var self = TopicCard
+ $('.showcard').fadeOut('fast')
+ self.openTopicCard = null
+ }
+}
+
+export default TopicCard
diff --git a/frontend/src/Metamaps/Views/index.js b/frontend/src/Metamaps/Views/index.js
index c496b3b0..85a710c3 100644
--- a/frontend/src/Metamaps/Views/index.js
+++ b/frontend/src/Metamaps/Views/index.js
@@ -4,18 +4,21 @@ import ExploreMaps from './ExploreMaps'
import ChatView from './ChatView'
import VideoView from './VideoView'
import Room from './Room'
+import TopicCard from './TopicCard'
import { JUNTO_UPDATED } from '../Realtime/events'
const Views = {
init: (serverData) => {
$(document).on(JUNTO_UPDATED, () => ExploreMaps.render())
ChatView.init([serverData['sounds/MM_sounds.mp3'], serverData['sounds/MM_sounds.ogg']])
+ TopicCard.init(serverData)
},
ExploreMaps,
ChatView,
VideoView,
- Room
+ Room,
+ TopicCard
}
-export { ExploreMaps, ChatView, VideoView, Room }
+export { ExploreMaps, ChatView, VideoView, Room, TopicCard }
export default Views
diff --git a/frontend/src/Metamaps/Visualize.js b/frontend/src/Metamaps/Visualize.js
index 2ccb08ed..43f6071b 100644
--- a/frontend/src/Metamaps/Visualize.js
+++ b/frontend/src/Metamaps/Visualize.js
@@ -9,7 +9,7 @@ import DataModel from './DataModel'
import JIT from './JIT'
import Loading from './Loading'
import Router from './Router'
-import TopicCard from './TopicCard'
+import TopicCard from './Views/TopicCard'
const Visualize = {
mGraph: null, // a reference to the graph object.
diff --git a/frontend/src/Metamaps/index.js b/frontend/src/Metamaps/index.js
index 61f5e18a..eb9969b0 100644
--- a/frontend/src/Metamaps/index.js
+++ b/frontend/src/Metamaps/index.js
@@ -29,7 +29,6 @@ import Settings from './Settings'
import Synapse from './Synapse'
import SynapseCard from './SynapseCard'
import Topic from './Topic'
-import TopicCard from './TopicCard'
import Util from './Util'
import Views from './Views'
import Visualize from './Visualize'
@@ -71,7 +70,6 @@ Metamaps.Settings = Settings
Metamaps.Synapse = Synapse
Metamaps.SynapseCard = SynapseCard
Metamaps.Topic = Topic
-Metamaps.TopicCard = TopicCard
Metamaps.Util = Util
Metamaps.Views = Views
Metamaps.Visualize = Visualize
diff --git a/frontend/src/components/ImportDialogBox.js b/frontend/src/components/ImportDialogBox.js
index 85b3ee67..9a9c777b 100644
--- a/frontend/src/components/ImportDialogBox.js
+++ b/frontend/src/components/ImportDialogBox.js
@@ -2,18 +2,8 @@ import React, { PropTypes, Component } from 'react'
import Dropzone from 'react-dropzone'
class ImportDialogBox extends Component {
- constructor(props) {
- super(props)
-
- this.state = {
- }
- }
-
- handleExport = format => () => {
- window.open(`${window.location.pathname}/export.${format}`, '_blank')
- }
-
handleFile = (files, e) => {
+ e.preventDefault() // prevent it from triggering the default drag-drop handler
this.props.onFileAdded(files[0])
}
@@ -21,13 +11,13 @@ class ImportDialogBox extends Component {
return (
EXPORT
-
+
Export as CSV
-
+
Export as JSON
-
+
Download screenshot
IMPORT
@@ -45,8 +35,8 @@ class ImportDialogBox extends Component {
ImportDialogBox.propTypes = {
onFileAdded: PropTypes.func,
- exampleImageUrl: PropTypes.string,
- downloadScreenshot: PropTypes.func
+ downloadScreenshot: PropTypes.func,
+ onExport: PropTypes.func
}
export default ImportDialogBox
diff --git a/frontend/src/components/Maps/MapCard.js b/frontend/src/components/Maps/MapCard.js
index 1ed7e492..4dd9ea18 100644
--- a/frontend/src/components/Maps/MapCard.js
+++ b/frontend/src/components/Maps/MapCard.js
@@ -1,5 +1,6 @@
import React, { Component, PropTypes } from 'react'
import { find, values } from 'lodash'
+import Util from '../../Metamaps/Util'
const IN_CONVERSATION = 1 // shared with /realtime/reducer.js
@@ -23,7 +24,8 @@ class Menu extends Component {
}
render = () => {
- const { currentUser, map, onStar, onRequest } = this.props
+ const { currentUser, map, onStar, onRequest, onFollow } = this.props
+ const isFollowing = map.isFollowedBy(currentUser)
const style = { display: this.state.open ? 'block' : 'none' }
return
@@ -35,6 +37,7 @@ class Menu extends Component {
- { this.toggle() && onStar(map) }}>Star Map
{ !map.authorizeToEdit(currentUser) && - { this.toggle() && onRequest(map) }}>Request Access
}
+ { Util.isTester(currentUser) && - { this.toggle() && onFollow(map) }}>{isFollowing ? 'Unfollow' : 'Follow'}
}
}
@@ -43,7 +46,8 @@ Menu.propTypes = {
currentUser: PropTypes.object.isRequired,
map: PropTypes.object.isRequired,
onStar: PropTypes.func.isRequired,
- onRequest: PropTypes.func.isRequired
+ onRequest: PropTypes.func.isRequired,
+ onFollow: PropTypes.func.isRequired
}
const Metadata = (props) => {
@@ -80,7 +84,7 @@ const checkAndWrapInA = (shouldWrap, classString, mapId, element) => {
class MapCard extends Component {
render = () => {
- const { map, mobile, juntoState, currentUser, onRequest, onStar } = this.props
+ const { map, mobile, juntoState, currentUser, onRequest, onStar, onFollow } = this.props
const hasMap = (juntoState.liveMaps[map.id] && values(juntoState.liveMaps[map.id]).length) || null
const realtimeMap = juntoState.liveMaps[map.id]
@@ -131,7 +135,7 @@ class MapCard extends Component {
) }
{ !mobile && hasMapper &&
}
{ !mobile && hasConversation &&
}
- { !mobile && currentUser &&
}
+ { !mobile && currentUser &&
}
) }
@@ -145,7 +149,8 @@ MapCard.propTypes = {
juntoState: PropTypes.object,
currentUser: PropTypes.object,
onStar: PropTypes.func.isRequired,
- onRequest: PropTypes.func.isRequired
+ onRequest: PropTypes.func.isRequired,
+ onFollow: PropTypes.func.isRequired
}
export default MapCard
diff --git a/frontend/src/components/Maps/index.js b/frontend/src/components/Maps/index.js
index 0478c34a..18d0fe92 100644
--- a/frontend/src/components/Maps/index.js
+++ b/frontend/src/components/Maps/index.js
@@ -46,7 +46,7 @@ class Maps extends Component {
}
render = () => {
- const { maps, currentUser, juntoState, pending, section, user, onStar, onRequest } = this.props
+ const { maps, currentUser, juntoState, pending, section, user, onStar, onRequest, onFollow } = this.props
const style = { width: this.state.mapsWidth + 'px' }
const mobile = document && document.body.clientWidth <= MOBILE_VIEW_BREAKPOINT
@@ -56,7 +56,7 @@ class Maps extends Component {
{ user ?
: null }
{ currentUser && !user && !(pending && maps.length === 0) ?
: null }
- { maps.models.map(map =>
) }
+ { maps.models.map(map =>
) }
@@ -79,7 +79,8 @@ Maps.propTypes = {
loadMore: PropTypes.func,
pending: PropTypes.bool.isRequired,
onStar: PropTypes.func.isRequired,
- onRequest: PropTypes.func.isRequired
+ onRequest: PropTypes.func.isRequired,
+ onFollow: PropTypes.func.isRequired
}
export default Maps
diff --git a/frontend/src/components/MetacodeSelect.js b/frontend/src/components/MetacodeSelect.js
new file mode 100644
index 00000000..68da09e8
--- /dev/null
+++ b/frontend/src/components/MetacodeSelect.js
@@ -0,0 +1,53 @@
+/* global $ */
+
+/*
+ * Metacode selector component
+ *
+ * This component takes in a callback (onMetacodeSelect; takes one metacode id)
+ * and a list of metacode sets and renders them. If you click a metacode, it
+ * passes that metacode's id to the callback.
+ */
+
+import React, { PropTypes, Component } from 'react'
+
+class MetacodeSelect extends Component {
+ render = () => {
+ return (
+
+ )
+ }
+}
+
+MetacodeSelect.propTypes = {
+ onMetacodeClick: PropTypes.func,
+ metacodeSets: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string,
+ metacodes: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number,
+ icon_path: PropTypes.string, // url
+ name: PropTypes.string
+ }))
+ }))
+}
+
+export default MetacodeSelect
diff --git a/frontend/src/components/TopicCard/Attachments.js b/frontend/src/components/TopicCard/Attachments.js
new file mode 100644
index 00000000..3e04dfbc
--- /dev/null
+++ b/frontend/src/components/TopicCard/Attachments.js
@@ -0,0 +1,24 @@
+import React, { PropTypes, Component } from 'react'
+
+import EmbedlyLink from './EmbedlyLink'
+
+class Attachments extends Component {
+ render = () => {
+ const { topic, authorizedToEdit, updateTopic } = this.props
+ const link = topic.get('link')
+
+ return (
+
+
+
+ )
+ }
+}
+
+Attachments.propTypes = {
+ topic: PropTypes.object, // Backbone object
+ authorizedToEdit: PropTypes.bool,
+ updateTopic: PropTypes.func
+}
+
+export default Attachments
diff --git a/frontend/src/components/TopicCard/Desc.js b/frontend/src/components/TopicCard/Desc.js
new file mode 100644
index 00000000..c94f30fb
--- /dev/null
+++ b/frontend/src/components/TopicCard/Desc.js
@@ -0,0 +1,77 @@
+import React, { PropTypes, Component } from 'react'
+import { RIETextArea } from 'riek'
+import Util from '../../Metamaps/Util'
+
+class MdTextArea extends RIETextArea {
+ keyDown = (event) => {
+ // we'll handle Enter on our own, thanks
+ const ESC = 27
+ if (event.keyCode === ESC) {
+ this.cancelEditing()
+ }
+ }
+
+ renderNormalComponent = () => {
+ // defaultProps MUST use dangerouslySetInnerHTML
+ return
+ }
+}
+
+class Desc extends Component {
+ render = () => {
+ const descHTML = (!this.props.desc && this.props.authorizedToEdit)
+ ? '
Click to add description...
'
+ : Util.mdToHTML(this.props.desc)
+
+ if (this.props.authorizedToEdit) {
+ return (
+
+
+
{
+ const ENTER = 13
+ if (!e.shiftKey && e.which === ENTER) {
+ e.preventDefault()
+ this.props.onChange({ desc: e.target.value })
+ }
+ }
+ }}
+ defaultProps={{
+ dangerouslySetInnerHTML: { __html: descHTML }
+ }}
+ />
+
+
+
+ )
+ } else {
+ return (
+
+
+
+ {this.props.desc}
+
+
+
+ )
+ }
+ }
+}
+
+Desc.propTypes = {
+ desc: PropTypes.string, // markdown
+ authorizedToEdit: PropTypes.bool,
+ onChange: PropTypes.func
+}
+
+export default Desc
diff --git a/frontend/src/components/TopicCard/EmbedlyLink/Card.js b/frontend/src/components/TopicCard/EmbedlyLink/Card.js
new file mode 100644
index 00000000..6d251310
--- /dev/null
+++ b/frontend/src/components/TopicCard/EmbedlyLink/Card.js
@@ -0,0 +1,65 @@
+/* global $, embedly */
+import React, { PropTypes, Component } from 'react'
+
+class EmbedlyCard extends Component {
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ embedlyLinkStarted: false,
+ embedlyLinkLoaded: false,
+ embedlyLinkError: false
+ }
+ }
+
+ componentDidMount = () => {
+ embedly('on', 'card.rendered', this.embedlyCardRendered)
+ if (this.props.link) this.loadLink()
+ }
+
+ componentWillUnmount = () => {
+ embedly('off')
+ }
+
+ componentDidUpdate = () => {
+ const { embedlyLinkStarted } = this.state
+ !embedlyLinkStarted && this.props.link && this.loadLink()
+ }
+
+ embedlyCardRendered = (iframe, test) => {
+ this.setState({embedlyLinkLoaded: true, embedlyLinkError: false})
+ }
+
+ loadLink = () => {
+ this.setState({ embedlyLinkStarted: true })
+ var e = embedly('card', document.getElementById('embedlyLink'))
+ if (e && e.type === 'error') this.setState({embedlyLinkError: true})
+ }
+
+ render = () => {
+ const { link } = this.props
+ const { embedlyLinkLoaded, embedlyLinkStarted, embedlyLinkError } = this.state
+
+ const notReady = embedlyLinkStarted && !embedlyLinkLoaded && !embedlyLinkError
+
+ return (
+
+ )
+ }
+}
+
+EmbedlyCard.propTypes = {
+ link: PropTypes.string
+}
+
+export default EmbedlyCard
diff --git a/frontend/src/components/TopicCard/EmbedlyLink/index.js b/frontend/src/components/TopicCard/EmbedlyLink/index.js
new file mode 100644
index 00000000..1775ab03
--- /dev/null
+++ b/frontend/src/components/TopicCard/EmbedlyLink/index.js
@@ -0,0 +1,76 @@
+/* global embedly */
+import React, { PropTypes, Component } from 'react'
+
+import Card from './Card'
+
+class EmbedlyLink extends Component {
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ linkEdit: ''
+ }
+ }
+
+ removeLink = () => {
+ this.props.updateTopic({ link: null })
+ }
+
+ resetLink = () => {
+ this.setState({ linkEdit: '' })
+ }
+
+ onLinkChangeHandler = e => {
+ this.setState({ linkEdit: e.target.value })
+ }
+
+ onLinkKeyUpHandler = e => {
+ const ENTER_KEY = 13
+ if (e.which === ENTER_KEY) {
+ const { linkEdit } = this.state
+ this.setState({ linkEdit: '' })
+ this.props.updateTopic({ link: linkEdit })
+ }
+ }
+
+ render = () => {
+ const { link, authorizedToEdit } = this.props
+ const { linkEdit } = this.state
+ const hasAttachment = !!link
+
+ if (!hasAttachment && !authorizedToEdit) return null
+
+ return (
+
+
+ {link &&
}
+ {authorizedToEdit && (
+
+ )}
+
+ )
+ }
+}
+
+EmbedlyLink.propTypes = {
+ link: PropTypes.string,
+ authorizedToEdit: PropTypes.bool,
+ updateTopic: PropTypes.func
+}
+
+export default EmbedlyLink
diff --git a/frontend/src/components/TopicCard/Follow.js b/frontend/src/components/TopicCard/Follow.js
new file mode 100644
index 00000000..786001d7
--- /dev/null
+++ b/frontend/src/components/TopicCard/Follow.js
@@ -0,0 +1,17 @@
+import React, { PropTypes, Component } from 'react'
+
+class Follow extends Component {
+ render = () => {
+ const { isFollowing, onFollow } = this.props
+ return
+ {isFollowing ? 'Unfollow' : 'Follow'}
+
+ }
+}
+
+Follow.propTypes = {
+ isFollowing: PropTypes.bool,
+ onFollow: PropTypes.func
+}
+
+export default Follow
diff --git a/frontend/src/components/TopicCard/Links.js b/frontend/src/components/TopicCard/Links.js
new file mode 100644
index 00000000..ad1758d3
--- /dev/null
+++ b/frontend/src/components/TopicCard/Links.js
@@ -0,0 +1,161 @@
+/* global $ */
+
+import React, { PropTypes, Component } from 'react'
+
+import MetacodeSelect from '../MetacodeSelect'
+import Permission from './Permission'
+
+class Links extends Component {
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ showMetacodeTitle: false,
+ showMetacodeSelect: false,
+ showInMaps: false,
+ showMoreMaps: false,
+ hoveringMapCount: false,
+ hoveringSynapseCount: false
+ }
+ }
+
+ handleMetacodeSelect = metacodeId => {
+ this.setState({ showMetacodeSelect: false })
+ this.props.updateTopic({
+ metacode_id: metacodeId
+ })
+ this.props.redrawCanvas()
+ }
+
+ toggleShowMoreMaps = e => {
+ e.stopPropagation()
+ e.preventDefault()
+ this.setState({ showMoreMaps: !this.state.showMoreMaps })
+ }
+
+ updateState = (key, value) => () => {
+ this.setState({ [key]: value })
+ }
+
+ inMaps = (topic) => {
+ const inmapsArray = topic.get('inmaps') || []
+ const inmapsLinks = topic.get('inmapsLinks') || []
+
+ let firstFiveLinks = []
+ let extraLinks = []
+ for (let i = 0; i < inmapsArray.length; i ++) {
+ if (i < 5) {
+ firstFiveLinks.push({ mapName: inmapsArray[i], mapId: inmapsLinks[i] })
+ } else {
+ extraLinks.push({ mapName: inmapsArray[i], mapId: inmapsLinks[i] })
+ }
+ }
+
+ let output = []
+
+ firstFiveLinks.forEach(obj => {
+ output.push(
{obj.mapName})
+ })
+
+ if (extraLinks.length > 0) {
+ if (this.state.showMoreMaps) {
+ extraLinks.forEach(obj => {
+ output.push(
{obj.mapName})
+ })
+ }
+ const text = this.state.showMoreMaps ? 'See less...' : `See ${extraLinks.length} more...`
+ output.push(
{text})
+ }
+
+ return output
+ }
+
+ handleMetacodeBarClick = () => {
+ if (this.state.showMetacodeTitle) {
+ this.setState({ showMetacodeSelect: !this.state.showMetacodeSelect })
+ }
+ }
+
+ render = () => {
+ const { topic, ActiveMapper } = this.props
+ const authorizedToEdit = topic.authorizeToEdit(ActiveMapper)
+ const authorizedPermissionChange = topic.authorizePermissionChange(ActiveMapper)
+ const metacode = topic.getMetacode()
+
+ return (
+
+
+ )
+ }
+}
+
+Links.propTypes = {
+ topic: PropTypes.object, // backbone object
+ ActiveMapper: PropTypes.object,
+ updateTopic: PropTypes.func,
+ metacodeSets: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string,
+ metacodes: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number,
+ icon_path: PropTypes.string, // url
+ name: PropTypes.string
+ }))
+ })),
+ redrawCanvas: PropTypes.func
+}
+
+export default Links
diff --git a/frontend/src/components/TopicCard/Permission.js b/frontend/src/components/TopicCard/Permission.js
new file mode 100644
index 00000000..bceb2d4c
--- /dev/null
+++ b/frontend/src/components/TopicCard/Permission.js
@@ -0,0 +1,69 @@
+import React, { PropTypes, Component } from 'react'
+
+import onClickOutsideAddon from 'react-onclickoutside'
+
+class Permission extends Component {
+ constructor(props) {
+ super(props)
+ this.state = {
+ selectingPermission: false
+ }
+ }
+
+ togglePermissionSelect = () => {
+ this.setState({selectingPermission: !this.state.selectingPermission})
+ }
+
+ openPermissionSelect = () => {
+ this.setState({selectingPermission: true})
+ }
+
+ closePermissionSelect = () => {
+ this.setState({selectingPermission: false})
+ }
+
+ handleClickOutside = instance => {
+ this.closePermissionSelect()
+ }
+
+ liClick = value => event => {
+ this.closePermissionSelect()
+ this.props.updateTopic({
+ permission: value,
+ defer_to_map_id: null
+ })
+ // prevents it from also firing the event listener on the parent
+ event.preventDefault()
+ }
+
+ render = () => {
+ const { permission, authorizedToEdit } = this.props
+ const { selectingPermission } = this.state
+
+ let classes = `linkItem mapPerm ${permission.substring(0, 2)}`
+ if (selectingPermission) classes += ' minimize'
+
+ return (
+
+
+ {permission !== 'commons' && }
+ {permission !== 'public' && }
+ {permission !== 'private' && }
+
+
+ )
+ }
+}
+
+Permission.propTypes = {
+ permission: PropTypes.string, // 'co', 'pu', or 'pr'
+ authorizedToEdit: PropTypes.bool,
+ updateTopic: PropTypes.func
+}
+
+export default onClickOutsideAddon(Permission)
diff --git a/frontend/src/components/TopicCard/Title.js b/frontend/src/components/TopicCard/Title.js
new file mode 100644
index 00000000..1eca527b
--- /dev/null
+++ b/frontend/src/components/TopicCard/Title.js
@@ -0,0 +1,62 @@
+import React, { Component, PropTypes } from 'react'
+import { RIETextArea } from 'riek'
+
+const maxTitleLength = 140
+
+class Title extends Component {
+ nameCounterText() {
+ // for some reason, there's an error if this isn't inside a function
+ return `${this.props.name.length}/${maxTitleLength.toString()}`
+ }
+
+ render() {
+ if (this.props.authorizedToEdit) {
+ return (
+
+ { this.textarea = textarea }}
+ propName="name"
+ change={this.props.onChange}
+ className="titleWrapper"
+ id="titleActivator"
+ classEditing="riek-editing"
+ editProps={{
+ maxLength: maxTitleLength,
+ onKeyPress: e => {
+ const ENTER = 13
+ if (e.which === ENTER) {
+ e.preventDefault()
+ this.props.onChange({ name: e.target.value })
+ }
+ },
+ onChange: e => {
+ if (!this.nameCounter) return
+ this.nameCounter.innerHTML = `${e.target.value.length}/140`
+ }
+ }}
+ />
+ { this.nameCounter = span }}>
+ {this.nameCounterText()}
+
+
+ )
+ } else {
+ return (
+
+
+ {this.props.name}
+
+
+ )
+ }
+ }
+}
+
+
+Title.propTypes = {
+ name: PropTypes.string,
+ onChange: PropTypes.func,
+ authorizedToEdit: PropTypes.bool
+}
+
+export default Title
diff --git a/frontend/src/components/TopicCard/index.js b/frontend/src/components/TopicCard/index.js
new file mode 100644
index 00000000..2c30d45e
--- /dev/null
+++ b/frontend/src/components/TopicCard/index.js
@@ -0,0 +1,71 @@
+import React, { PropTypes, Component } from 'react'
+
+import Title from './Title'
+import Links from './Links'
+import Desc from './Desc'
+import Attachments from './Attachments'
+import Follow from './Follow'
+import Util from '../../Metamaps/Util'
+
+
+class ReactTopicCard extends Component {
+ render = () => {
+ const { topic, ActiveMapper, onFollow } = this.props
+ const authorizedToEdit = topic.authorizeToEdit(ActiveMapper)
+ const isFollowing = topic.isFollowedBy(ActiveMapper)
+ const hasAttachment = topic.get('link') && topic.get('link') !== ''
+
+ let classname = 'permission'
+ if (authorizedToEdit) {
+ classname += ' canEdit'
+ } else {
+ classname += ' cannotEdit'
+ }
+ if (topic.authorizePermissionChange(ActiveMapper)) classname += ' yourTopic'
+
+ return (
+
+
+
+
+
+
+ {Util.isTester(ActiveMapper) &&
}
+
+
+
+ )
+ }
+}
+
+ReactTopicCard.propTypes = {
+ topic: PropTypes.object,
+ ActiveMapper: PropTypes.object,
+ updateTopic: PropTypes.func,
+ onFollow: PropTypes.func,
+ metacodeSets: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string,
+ metacodes: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number,
+ icon_path: PropTypes.string, // url
+ name: PropTypes.string
+ }))
+ })),
+ redrawCanvas: PropTypes.func
+}
+
+export default ReactTopicCard
diff --git a/frontend/src/patched/JIT.js b/frontend/src/patched/JIT.js
index e780604e..5ea04ce1 100644
--- a/frontend/src/patched/JIT.js
+++ b/frontend/src/patched/JIT.js
@@ -449,7 +449,7 @@ $.event = {
isRightClick: function(e) {
return (e.which == 3 || e.button == 2);
},
- getPos: function(e, win) {
+ getPos: function(e, win, touchIndex) {
// get mouse position
win = win || window;
e = e || win.event;
@@ -457,7 +457,7 @@ $.event = {
doc = doc.documentElement || doc.body;
//TODO(nico): make touch event handling better
if(e.touches && e.touches.length) {
- e = e.touches[0];
+ e = e.touches[touchIndex || 0];
}
var page = {
x: e.pageX || (e.clientX + doc.scrollLeft),
@@ -2469,33 +2469,7 @@ Extras.Classes.Navigation = new Class({
// START METAMAPS CODE
if (((ans > 1) && (5 >= this.canvas.scaleOffsetX)) || ((ans < 1) && (this.canvas.scaleOffsetX >= 0.2))) {
- var s = this.canvas.getSize(),
- p = this.canvas.getPos(),
- ox = this.canvas.translateOffsetX,
- oy = this.canvas.translateOffsetY,
- sx = this.canvas.scaleOffsetX,
- sy = this.canvas.scaleOffsetY;
-
- //Basically this is just a duplication of the Util function pixelsToCoords, it finds the canvas coordinate of the mouse pointer
- var pointerCoordX = (e.pageX - p.x - s.width / 2 - ox) * (1 / sx),
- pointerCoordY = (e.pageY - p.y - s.height / 2 - oy) * (1 / sy);
-
- //This translates the canvas to be centred over the mouse pointer, then the canvas is zoomed as intended.
- this.canvas.translate(-pointerCoordX,-pointerCoordY);
- this.canvas.scale(ans, ans);
-
- //Get the canvas attributes again now that is has changed
- s = this.canvas.getSize(),
- p = this.canvas.getPos(),
- ox = this.canvas.translateOffsetX,
- oy = this.canvas.translateOffsetY,
- sx = this.canvas.scaleOffsetX,
- sy = this.canvas.scaleOffsetY;
- var newX = (e.pageX - p.x - s.width / 2 - ox) * (1 / sx),
- newY = (e.pageY - p.y - s.height / 2 - oy) * (1 / sy);
-
- //Translate the canvas to put the pointer back over top the same coordinate it was over before
- this.canvas.translate(newX-pointerCoordX,newY-pointerCoordY);
+ Metamaps.Util.zoomOnPoint(this, ans, {x: e.pageX, y: e.pageY})
}
// END METAMAPS CODE
@@ -2620,109 +2594,132 @@ Extras.Classes.Navigation = new Class({
Metamaps.Mouse.changeInY = 0;
if((this.config.panning == 'avoid nodes' && eventInfo.getNode()) || eventInfo.getEdge()) return;
this.pressed = true;
- var rightClick = e.button == 2 || (navigator.platform.indexOf("Mac") != -1 && e.ctrlKey);
- if (!Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) {
- Metamaps.Mouse.boxStartCoordinates = eventInfo.getPos();
- }
Metamaps.Mouse.didPan = false;
- this.pos = eventInfo.getPos();
+
var canvas = this.canvas,
ox = canvas.translateOffsetX,
oy = canvas.translateOffsetY,
sx = canvas.scaleOffsetX,
sy = canvas.scaleOffsetY;
- this.pos.x *= sx;
- this.pos.x += ox;
- this.pos.y *= sy;
- this.pos.y += oy;
+
+ if (e.touches.length === 1) {
+ this.pos = eventInfo.getPos();
+ } else if (e.touches.length === 2) {
+ var s = canvas.getSize(),
+ pos1 = $.event.getPos(e, win, 0),
+ pos2 = $.event.getPos(e, win, 1),
+ touch1 = {
+ x: (pos1.x - s.width/2 - ox) * 1/sx,
+ y: (pos1.y - s.height/2 - oy) * 1/sy
+ },
+ touch2 = {
+ x: (pos2.x - s.width/2 - ox) * 1/sx,
+ y: (pos2.y - s.height/2 - oy) * 1/sy
+ };
+ this.pos = {
+ x: (touch1.x + touch2.x) / 2,
+ y: (touch1.y + touch2.y) / 2
+ }
+ this.unitRadius = Metamaps.Util.getDistance(touch1, touch2) / 2
+ }
+ if (e.touches.length === 1 || e.touches.length === 2) {
+ this.pos.x *= sx;
+ this.pos.x += ox;
+ this.pos.y *= sy;
+ this.pos.y += oy;
+ }
},
onTouchMove: function(e, win, eventInfo) {
+ e.preventDefault()
if(!this.config.panning) return;
if(!this.pressed) return;
- if(this.config.panning == 'avoid nodes' && (this.dom? this.isLabel(e, win) : eventInfo.getNode())) return;
+ var canvas = this.canvas,
+ ox = canvas.translateOffsetX,
+ oy = canvas.translateOffsetY,
+ sx = canvas.scaleOffsetX,
+ sy = canvas.scaleOffsetY,
+ beforePos = this.pos,
+ currentPos,
+ touch1,
+ touch2;
+
if (e.touches.length == 1) {
- var rightClick = e.button == 2 || (navigator.platform.indexOf("Mac") != -1 && e.ctrlKey);
- if (!Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) {
- Metamaps.Visualize.mGraph.busy = true;
- Metamaps.boxStartCoordinates = eventInfo.getPos();
- return;
+ currentPos = eventInfo.getPos()
+ } else if (e.touches.length >= 2) {
+ var s = canvas.getSize(),
+ pos1 = $.event.getPos(e, win, 0),
+ pos2 = $.event.getPos(e, win, 1),
+ touch1 = {
+ x: (pos1.x - s.width/2 - ox) * 1/sx,
+ y: (pos1.y - s.height/2 - oy) * 1/sy
+ },
+ touch2 = {
+ x: (pos2.x - s.width/2 - ox) * 1/sx,
+ y: (pos2.y - s.height/2 - oy) * 1/sy
+ };
+ currentPos = {
+ x: (touch1.x + touch2.x) / 2,
+ y: (touch1.y + touch2.y) / 2
+ }
+ }
+ currentPos.x *= sx;
+ currentPos.y *= sy;
+ currentPos.x += ox;
+ currentPos.y += oy;
+ Metamaps.Mouse.didPan = true;
+ var x = currentPos.x - beforePos.x,
+ y = currentPos.y - beforePos.y;
+ Metamaps.Mouse.changeInX = x;
+ Metamaps.Mouse.changeInY = y;
+ this.pos = currentPos;
+ canvas.translate(x * 1/sx, y * 1/sy);
+ jQuery(document).trigger(Metamaps.JIT.events.pan);
+
+ if (e.touches.length >= 2) {
+ var currentPixelRadius = Metamaps.Util.getDistance({
+ x: e.touches[0].clientX,
+ y: e.touches[0].clientY
+ }, {
+ x: e.touches[1].clientX,
+ y: e.touches[1].clientY
+ }) / 2
+ var desiredScale = currentPixelRadius / this.unitRadius
+ var scaler = desiredScale / sx
+ var midpoint = {
+ x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
+ y: (e.touches[0].clientY + e.touches[1].clientY) / 2
+ }
+ if (30 >= desiredScale && desiredScale >= 0.2) {
+ Metamaps.Util.zoomOnPoint(this, scaler, midpoint)
+ jQuery(document).trigger(Metamaps.JIT.events.zoom)
}
- if (Metamaps.Mouse.boxStartCoordinates && ((e.button == 0 && e.shiftKey) || (e.button == 0 && e.ctrlKey) || rightClick)) {
- Metamaps.Visualize.mGraph.busy = true;
- Metamaps.JIT.drawSelectBox(eventInfo,e);
- return;
- }
- if (rightClick){
- return;
- }
- if (e.target.id != 'infovis-canvas') {
- this.pressed = false;
- return;
- }
- Metamaps.Mouse.didPan = true;
- var thispos = this.pos,
- currentPos = eventInfo.getPos(),
- canvas = this.canvas,
- ox = canvas.translateOffsetX,
- oy = canvas.translateOffsetY,
- sx = canvas.scaleOffsetX,
- sy = canvas.scaleOffsetY;
- currentPos.x *= sx;
- currentPos.y *= sy;
- currentPos.x += ox;
- currentPos.y += oy;
- var x = currentPos.x - thispos.x,
- y = currentPos.y - thispos.y;
- Metamaps.Mouse.changeInX = x;
- Metamaps.Mouse.changeInY = y;
- this.pos = currentPos;
- this.canvas.translate(x * 1/sx, y * 1/sy);
- jQuery(document).trigger(Metamaps.JIT.events.pan);
- }
- /*
- else if (e.touches.length == 2) {
- var touch1 = e.touches[0]
- var touch2 = e.touches[1]
- var canvas = this.canvas
-
- callCount++;
-
- var dist = Metamaps.Util.getDistance({
- x: touch1.clientX,
- y: touch1.clientY
- }, {
- x: touch2.clientX,
- y: touch2.clientY
- })
-
- if (!this.initDist) {
- this.initDist = dist
- this.initScale = canvas.scaleOffsetX
- }
- var scale = (dist / this.initDist)
-
- document.getElementById("header_content").innerHTML = scale + ' ' + canvas.scaleOffsetX
- if (30 >= this.initScale * scale && this.initScale * scale >= 0.2) {
- canvas.scale(this.initScale * scale, this.initScale * scale)
- }
- if (canvas.scaleOffsetX < 0.5) {
- canvas.viz.labels.hideLabels(true)
- } else if (canvas.scaleOffsetX > 0.5) {
- canvas.viz.labels.hideLabels(false)
- }
-
- jQuery(document).trigger(Metamaps.JIT.events.zoom);
}
- */
},
onTouchEnd: function(e, win, eventInfo, isRightClick) {
if(!this.config.panning) return;
- this.pressed = false;
- if (Metamaps.Mouse.didPan) Metamaps.JIT.SmoothPanning();
- this.initDist = false
+ if (e.touches.length === 1) {
+ var canvas = this.canvas,
+ ox = canvas.translateOffsetX,
+ oy = canvas.translateOffsetY,
+ sx = canvas.scaleOffsetX,
+ sy = canvas.scaleOffsetY,
+ s = canvas.getSize();
+ this.pos = {
+ x: (e.touches[0].clientX - s.width/2 - ox) * 1/sx,
+ y: (e.touches[0].clientY - s.height/2 - oy) * 1/sy
+ };
+ this.pos.x *= sx;
+ this.pos.x += ox;
+ this.pos.y *= sy;
+ this.pos.y += oy;
+ } else if (e.touches.length === 0) {
+ this.pressed = false;
+ this.pos = null
+ if (Metamaps.Mouse.didPan) Metamaps.JIT.SmoothPanning();
+ }
}
// END METAMAPS CODE
});
diff --git a/frontend/test/Metamaps.Import.spec.js b/frontend/test/Metamaps/Import.spec.js
similarity index 100%
rename from frontend/test/Metamaps.Import.spec.js
rename to frontend/test/Metamaps/Import.spec.js
diff --git a/frontend/test/Metamaps.Util.spec.js b/frontend/test/Metamaps/Util.spec.js
similarity index 91%
rename from frontend/test/Metamaps.Util.spec.js
rename to frontend/test/Metamaps/Util.spec.js
index e0366bd9..80108ee2 100644
--- a/frontend/test/Metamaps.Util.spec.js
+++ b/frontend/test/Metamaps/Util.spec.js
@@ -113,9 +113,15 @@ describe('Metamaps.Util.js', function() {
expect(Util.mdToHTML(md).trim()).to.equal(html)
})
- it('links and images', function() {
- const md = '[Link](https://metamaps.cc) '
- const html = '
Link 
'
+ it('links', function() {
+ const md = '[Link](https://metamaps.cc)'
+ const html = '
Link
'
+ expect(Util.mdToHTML(md).trim()).to.equal(html)
+ })
+
+ it('images are not rendered', function() {
+ const md = ''
+ const html = '

'
expect(Util.mdToHTML(md).trim()).to.equal(html)
})
})
diff --git a/frontend/test/components/ImportDialogBox.spec.js b/frontend/test/components/ImportDialogBox.spec.js
new file mode 100644
index 00000000..f14e04b3
--- /dev/null
+++ b/frontend/test/components/ImportDialogBox.spec.js
@@ -0,0 +1,50 @@
+/* global describe, it */
+import React from 'react'
+import TestUtils from 'react-addons-test-utils' // ES6
+import ImportDialogBox from '../../src/components/ImportDialogBox.js'
+import Dropzone from 'react-dropzone'
+import chai from 'chai'
+
+const { expect } = chai
+
+describe('ImportDialogBox', function() {
+ it('has an Export CSV button', function(done) {
+ const onExport = format => {
+ if (format === 'csv') done()
+ }
+ const detachedComp = TestUtils.renderIntoDocument(
)
+ 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(
)
+ 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(
)
+ 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(
{}} onFileAdded={onFileAdded} />)
+ const dropzone = TestUtils.findRenderedComponentWithType(detachedComp, Dropzone)
+ expect(dropzone).to.exist;
+ dropzone.props.onDropAccepted([uploadedFile], { preventDefault: () => {} })
+ })
+})
diff --git a/frontend/test/support/dom.js b/frontend/test/support/dom.js
new file mode 100644
index 00000000..af2c1bf9
--- /dev/null
+++ b/frontend/test/support/dom.js
@@ -0,0 +1,25 @@
+const jsdom = require('jsdom')
+const doc = jsdom.jsdom('')
+const win = doc.defaultView
+
+global.document = doc
+global.window = win
+
+// take all properties of the window object and also attach it to the
+// mocha global object
+propagateToGlobal(win)
+
+// from mocha-jsdom https://github.com/rstacruz/mocha-jsdom/blob/master/index.js#L80
+function propagateToGlobal (window) {
+ for (let key in window) {
+ if (!window.hasOwnProperty(key)) continue
+ if (key in global) continue
+
+ global[key] = window[key]
+ }
+}
+
+// Metamaps dependencies fixes
+global.HowlerGlobal = global.HowlerGlobal || { prototype: {} }
+global.Howl = global.Howl || { prototype: {} }
+global.Sound = global.Sound || { prototype: {} }
diff --git a/lib/tasks/emails.rake b/lib/tasks/emails.rake
new file mode 100644
index 00000000..b2a7303a
--- /dev/null
+++ b/lib/tasks/emails.rake
@@ -0,0 +1,20 @@
+namespace :metamaps do
+ desc "delivers recent map activity digest emails to users"
+ task deliver_map_activity_emails: :environment do
+ summarize_map_activity
+ end
+
+ def summarize_map_activity
+ Follow.where(followed_type: 'Map').find_each do |follow|
+ map = follow.followed
+ user = follow.user
+ # add logging and rescue-ing
+ # and a notification of failure
+ next unless MapPolicy.new(user, map).show? # just in case the permission changed
+ next unless user.emails_allowed
+ summary_data = MapActivityService.summarize_data(map, user)
+ next if summary_data[:stats].blank?
+ MapActivityMailer.daily_summary(user, map, summary_data).deliver_later
+ end
+ end
+end
diff --git a/package.json b/package.json
index 104bcc5e..84eff416 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"scripts": {
"build": "webpack",
"build:watch": "webpack --watch",
- "test": "mocha --compilers js:babel-core/register frontend/test",
+ "test": "mocha-webpack --webpack-config webpack.test.config.js --require frontend/test/support/dom.js frontend/test",
"eslint": "eslint frontend",
"eslint:fix": "eslint --fix frontend"
},
@@ -35,7 +35,7 @@
"csv-parse": "1.1.10",
"emoji-mart": "0.3.7",
"getscreenmedia": "2.0.0",
- "hark": "git://github.com/otalk/hark#342ef9b7eff2",
+ "hark": "1.1.5",
"howler": "2.0.2",
"jquery": "3.1.1",
"json-loader": "0.5.4",
@@ -45,10 +45,12 @@
"react": "15.4.2",
"react-dom": "15.4.2",
"react-dropzone": "3.9.1",
+ "react-onclickoutside": "5.9.0",
"redux": "3.6.0",
+ "riek": "1.0.7",
"simplewebrtc": "2.2.2",
"socket.io": "1.3.7",
- "webpack": "1.14.0"
+ "webpack": "2.2.1"
},
"devDependencies": {
"babel-eslint": "^7.1.1",
@@ -59,7 +61,10 @@
"eslint-plugin-promise": "^3.4.0",
"eslint-plugin-react": "^6.8.0",
"eslint-plugin-standard": "^2.0.1",
- "mocha": "^3.2.0"
+ "jsdom": "^9.11.0",
+ "mocha": "^3.2.0",
+ "mocha-webpack": "^0.7.0",
+ "react-addons-test-utils": "^15.4.2"
},
"optionalDependencies": {
"raml2html": "4.0.5"
diff --git a/spec/factories/message.rb b/spec/factories/message.rb
new file mode 100644
index 00000000..a0930fec
--- /dev/null
+++ b/spec/factories/message.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+FactoryGirl.define do
+ factory :message do
+ association :resource, factory: :map
+ user
+ sequence(:message) { |n| "Cool Message ##{n}" }
+ end
+end
diff --git a/spec/mailers/map_activity_mailer_spec.rb b/spec/mailers/map_activity_mailer_spec.rb
new file mode 100644
index 00000000..897a38c0
--- /dev/null
+++ b/spec/mailers/map_activity_mailer_spec.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe MapActivityMailer, type: :mailer do
+
+end
diff --git a/spec/mailers/previews/map_activity_mailer_preview.rb b/spec/mailers/previews/map_activity_mailer_preview.rb
new file mode 100644
index 00000000..3d943eee
--- /dev/null
+++ b/spec/mailers/previews/map_activity_mailer_preview.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+# Preview all emails at http://localhost:3000/rails/mailers/map_activity_mailer
+class MapActivityMailerPreview < ActionMailer::Preview
+ def daily_summary
+ user = generate_user
+ map = generate_map
+ generate_recent_activity_on_map(map)
+ summary_data = MapActivityService.summarize_data(map, user)
+ MapActivityMailer.daily_summary(user, map, summary_data)
+ end
+
+ private
+ def generate_recent_activity_on_map(map)
+ mapping = nil
+ mapping2 = nil
+ mapping3 = nil
+ mapping4 = nil
+ mapping5 = nil
+ mapping6 = nil
+ mapping7 = nil
+ mapping8 = nil
+ mapping9 = nil
+ mapping10 = nil
+
+ Timecop.freeze(2.days.ago) do
+ mapping = topic_added_to_map(map)
+ mapping2 = topic_added_to_map(map)
+ mapping3 = topic_added_to_map(map)
+ mapping4 = topic_added_to_map(map)
+ mapping5 = topic_added_to_map(map)
+ mapping6 = topic_added_to_map(map)
+ mapping7 = topic_added_to_map(map)
+ mapping8 = topic_added_to_map(map)
+ mapping9 = synapse_added_to_map(map, mapping.mappable, mapping2.mappable)
+ mapping10 = synapse_added_to_map(map, mapping.mappable, mapping8.mappable)
+ end
+ Timecop.return
+
+ Timecop.freeze(2.hours.ago) do
+ topic_moved_on_map(mapping7)
+ topic_moved_on_map(mapping8)
+ generate_message(map)
+ generate_message(map)
+ generate_message(map)
+ synapse_added_to_map(map, mapping7.mappable, mapping8.mappable)
+ synapse_added_to_map(map, mapping.mappable, mapping8.mappable)
+ synapse_removed_from_map(mapping9)
+ synapse_removed_from_map(mapping10)
+ end
+ Timecop.return
+
+ Timecop.freeze(30.minutes.ago) do
+ topic_removed_from_map(mapping3)
+ topic_removed_from_map(mapping4)
+ topic_removed_from_map(mapping5)
+ topic_removed_from_map(mapping6)
+ topic_added_to_map(map)
+ topic_added_to_map(map)
+ topic_added_to_map(map)
+ topic_added_to_map(map)
+ topic_added_to_map(map)
+ topic_added_to_map(map)
+ topic_added_to_map(map)
+ topic_added_to_map(map)
+ end
+ Timecop.return
+ end
+
+ def generate_user
+ User.create(name: Faker::Name.name, email: Faker::Internet.email, password: "password", password_confirmation: "password", joinedwithcode: 'qwertyui')
+ end
+
+ def generate_map
+ Map.create(name: Faker::HarryPotter.book, permission: 'commons', arranged: false, user: generate_user)
+ end
+
+ def topic_added_to_map(map)
+ user = generate_user
+ topic = Topic.create(name: Faker::Friends.quote, permission: 'commons', user: user)
+ mapping = Mapping.create(map: map, mappable: topic, user: user)
+ end
+
+ def topic_moved_on_map(mapping)
+ meta = { 'x': 10, 'y': 20, 'mapping_id': mapping.id }
+ Events::TopicMovedOnMap.publish!(mapping.mappable, mapping.map, generate_user, meta)
+ end
+
+ def topic_removed_from_map(mapping)
+ user = generate_user
+ mapping.updated_by = user
+ mapping.destroy
+ end
+
+ def synapse_added_to_map(map, topic1, topic2)
+ user = generate_user
+ topic = Synapse.create(desc: 'describes', permission: 'commons', user: user, topic1: topic1, topic2: topic2)
+ mapping = Mapping.create(map: map, mappable: topic, user: user)
+ end
+
+ def synapse_removed_from_map(mapping)
+ user = generate_user
+ mapping.updated_by = user
+ mapping.destroy
+ end
+
+ def generate_message(map)
+ Message.create(message: Faker::HarryPotter.quote, resource: map, user: generate_user)
+ end
+end
diff --git a/spec/mailers/previews/map_mailer_preview.rb b/spec/mailers/previews/map_mailer_preview.rb
index 05368443..9766c0da 100644
--- a/spec/mailers/previews/map_mailer_preview.rb
+++ b/spec/mailers/previews/map_mailer_preview.rb
@@ -6,12 +6,12 @@ class MapMailerPreview < ActionMailer::Preview
MapMailer.invite_to_edit(user_map)
end
- def access_request_email
+ def access_request
request = AccessRequest.first
MapMailer.access_request(request)
end
- def access_approved_email
+ def access_approved
request = AccessRequest.first
MapMailer.access_approved(request)
end
diff --git a/spec/mailers/previews/topic_mailer_preview.rb b/spec/mailers/previews/topic_mailer_preview.rb
new file mode 100644
index 00000000..7ce12b8d
--- /dev/null
+++ b/spec/mailers/previews/topic_mailer_preview.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+# Preview all emails at http://localhost:3000/rails/mailers/topic_mailer
+class TopicMailerPreview < ActionMailer::Preview
+ def added_to_map
+ event = Event.where(kind: 'topic_added_to_map').first
+ user = User.first
+ TopicMailer.added_to_map(event, user)
+ end
+
+ def connected
+ synapse = Synapse.first
+ topic = synapse.topic1
+ user = User.first
+ TopicMailer.connected(synapse, topic, user)
+ end
+end
diff --git a/spec/services/map_activity_service_spec.rb b/spec/services/map_activity_service_spec.rb
new file mode 100644
index 00000000..fe283e01
--- /dev/null
+++ b/spec/services/map_activity_service_spec.rb
@@ -0,0 +1,398 @@
+require 'rails_helper'
+
+RSpec.describe MapActivityService do
+ let(:map) { create(:map, created_at: 1.week.ago) }
+ let(:other_user) { create(:user) }
+ let(:email_user) { create(:user) }
+ let(:empty_response) { {stats:{}} }
+
+ it 'includes nothing if nothing happened' do
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ describe 'topics added to map' do
+ it 'includes a topic added within the last 24 hours' do
+ topic = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 6.hours.ago)
+ event = Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id)
+ event.update_columns(created_at: 6.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:topics_added]).to eq(1)
+ expect(response[:topics_added]).to eq([event])
+ end
+
+ it 'includes a topic added, then removed, then re-added within the last 24 hours' do
+ topic = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 6.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 6.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id).update_columns(created_at: 5.hours.ago)
+ mapping2 = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 4.hours.ago)
+ event = Event.where("meta->>'mapping_id' = ?", mapping2.id.to_s).first
+ event.update_columns(created_at: 4.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:topics_added]).to eq(1)
+ expect(response[:topics_added]).to eq([event])
+ end
+
+ it 'excludes a topic removed then re-added within the last 24 hours' do
+ topic = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id).update_columns(created_at: 6.hours.ago)
+ mapping2 = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 5.hours.ago)
+ Event.where(kind: 'topic_added_to_map').where("meta->>'mapping_id' = ?", mapping2.id.to_s).first.update_columns(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ it 'excludes a topic added outside the last 24 hours' do
+ topic = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ it 'excludes topics added by the user who will receive the data' do
+ topic = create(:topic)
+ topic2 = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 5.hours.ago)
+ event = Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id)
+ event.update_columns(created_at: 5.hours.ago)
+ mapping2 = create(:mapping, user: email_user, map: map, mappable: topic2, created_at: 5.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic2.id).update_columns(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:topics_added]).to eq(1)
+ expect(response[:topics_added]).to eq([event])
+ end
+ end
+
+ describe 'topics moved on map' do
+ it 'includes ones moved within the last 24 hours' do
+ topic = create(:topic)
+ create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago)
+ event = Events::TopicMovedOnMap.publish!(topic, map, other_user, {})
+ event.update(created_at: 6.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:topics_moved]).to eq(1)
+ end
+
+ it 'only includes each topic that was moved in the count once' do
+ topic = create(:topic)
+ topic2 = create(:topic)
+ create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago)
+ create(:mapping, user: email_user, map: map, mappable: topic2, created_at: 5.hours.ago)
+ event = Events::TopicMovedOnMap.publish!(topic, map, other_user, {})
+ event.update(created_at: 6.hours.ago)
+ event2 = Events::TopicMovedOnMap.publish!(topic, map, other_user, {})
+ event2.update(created_at: 5.hours.ago)
+ event3 = Events::TopicMovedOnMap.publish!(topic2, map, other_user, {})
+ event3.update(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:topics_moved]).to eq(2)
+ end
+
+ it 'excludes ones moved outside the last 24 hours' do
+ topic = create(:topic)
+ create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago)
+ event = Events::TopicMovedOnMap.publish!(topic, map, other_user, {})
+ event.update(created_at: 25.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ it 'excludes ones moved by the user who will receive the data' do
+ topic = create(:topic)
+ create(:mapping, user: email_user, map: map, mappable: topic, created_at: 5.hours.ago)
+ event = Events::TopicMovedOnMap.publish!(topic, map, email_user, {})
+ event.update(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+ end
+
+ describe 'topics removed from map' do
+ it 'includes a topic removed within the last 24 hours' do
+ topic = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ event = Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id)
+ event.update_columns(created_at: 6.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:topics_removed]).to eq(1)
+ expect(response[:topics_removed]).to eq([event])
+ end
+
+ it 'excludes a topic removed outside the last 24 hours' do
+ topic = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 26.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 26.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ it 'excludes topics removed by the user who will receive the data' do
+ topic = create(:topic)
+ topic2 = create(:topic)
+ mapping = create(:mapping, user: other_user, map: map, mappable: topic, created_at: 25.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic.id).update_columns(created_at: 25.hours.ago)
+ mapping2 = create(:mapping, user: email_user, map: map, mappable: topic2, created_at: 25.hours.ago)
+ Event.find_by(kind: 'topic_added_to_map', eventable_id: topic2.id).update_columns(created_at: 25.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ mapping2.updated_by = email_user
+ mapping2.destroy
+ event = Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic.id)
+ event.update_columns(created_at: 5.hours.ago)
+ Event.find_by(kind: 'topic_removed_from_map', eventable_id: topic2.id).update_columns(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:topics_removed]).to eq(1)
+ expect(response[:topics_removed]).to eq([event])
+ end
+ end
+
+ describe 'synapses added to map' do
+ it 'includes a synapse added within the last 24 hours' do
+ synapse = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 6.hours.ago)
+ event = Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id)
+ event.update_columns(created_at: 6.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:synapses_added]).to eq(1)
+ expect(response[:synapses_added]).to eq([event])
+ end
+
+ it 'includes a synapse added, then removed, then re-added within the last 24 hours' do
+ synapse = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 6.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 6.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id).update_columns(created_at: 5.hours.ago)
+ mapping2 = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 4.hours.ago)
+ event = Event.where(kind: 'synapse_added_to_map').where("meta->>'mapping_id' = ?", mapping2.id.to_s).first
+ event.update_columns(created_at: 4.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:synapses_added]).to eq(1)
+ expect(response[:synapses_added]).to eq([event])
+ end
+
+ it 'excludes a synapse removed then re-added within the last 24 hours' do
+ synapse = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id).update_columns(created_at: 6.hours.ago)
+ mapping2 = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 5.hours.ago)
+ Event.where(kind: 'synapse_added_to_map').where("meta->>'mapping_id' = ?", mapping2.id.to_s).first.update_columns(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ it 'excludes a synapse added outside the last 24 hours' do
+ synapse = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ it 'excludes synapses added by the user who will receive the data' do
+ synapse = create(:synapse)
+ synapse2 = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 5.hours.ago)
+ event = Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id)
+ event.update_columns(created_at: 5.hours.ago)
+ mapping2 = create(:mapping, user: email_user, map: map, mappable: synapse2, created_at: 5.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse2.id).update_columns(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:synapses_added]).to eq(1)
+ expect(response[:synapses_added]).to eq([event])
+ end
+ end
+
+ describe 'synapses removed from map' do
+ it 'includes a synapse removed within the last 24 hours' do
+ synapse = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ event = Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id)
+ event.update_columns(created_at: 6.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:synapses_removed]).to eq(1)
+ expect(response[:synapses_removed]).to eq([event])
+ end
+
+ it 'excludes a synapse removed outside the last 24 hours' do
+ synapse = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response).to eq (empty_response)
+ end
+
+ it 'excludes synapses removed by the user who will receive the data' do
+ synapse = create(:synapse)
+ synapse2 = create(:synapse)
+ mapping = create(:mapping, user: other_user, map: map, mappable: synapse, created_at: 25.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse.id).update_columns(created_at: 25.hours.ago)
+ mapping2 = create(:mapping, user: email_user, map: map, mappable: synapse2, created_at: 25.hours.ago)
+ Event.find_by(kind: 'synapse_added_to_map', eventable_id: synapse2.id).update_columns(created_at: 25.hours.ago)
+ mapping.updated_by = other_user
+ mapping.destroy
+ mapping2.updated_by = email_user
+ mapping2.destroy
+ event = Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse.id)
+ event.update_columns(created_at: 5.hours.ago)
+ Event.find_by(kind: 'synapse_removed_from_map', eventable_id: synapse2.id).update_columns(created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:synapses_removed]).to eq(1)
+ expect(response[:synapses_removed]).to eq([event])
+ end
+ end
+
+ it 'handles permissions for topics added' do
+ new_topic = nil
+ new_private_topic = nil
+
+ Timecop.freeze(10.hours.ago) do
+ new_topic = create(:topic, permission: 'commons', user: other_user)
+ create(:mapping, map: map, mappable: new_topic, user: other_user)
+ new_private_topic = create(:topic, permission: 'private', user: other_user)
+ create(:mapping, map: map, mappable: new_private_topic, user: other_user)
+ end
+ Timecop.return
+
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats]).to eq({
+ topics_added: 1
+ })
+ expect(response[:topics_added].map(&:eventable_id)).to include(new_topic.id)
+ expect(response[:topics_added].map(&:eventable_id)).to_not include(new_private_topic.id)
+ end
+
+ it 'handles permissions for topics removed' do
+ old_topic = nil
+ old_private_topic = nil
+ old_topic_mapping = nil
+ old_private_topic_mapping = nil
+
+ Timecop.freeze(2.days.ago) do
+ old_topic = create(:topic, permission: 'commons', user: other_user)
+ old_topic_mapping = create(:mapping, map: map, mappable: old_topic, user: other_user)
+ old_private_topic = create(:topic, permission: 'private', user: other_user)
+ old_private_topic_mapping = create(:mapping, map: map, mappable: old_private_topic, user: other_user)
+ end
+ Timecop.return
+
+ Timecop.freeze(10.hours.ago) do
+ # visible
+ old_topic_mapping.updated_by = other_user
+ old_topic_mapping.destroy
+ # not visible
+ old_private_topic_mapping.updated_by = other_user
+ old_private_topic_mapping.destroy
+ end
+ Timecop.return
+
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats]).to eq({
+ topics_removed: 1
+ })
+ expect(response[:topics_removed].map(&:eventable_id)).to include(old_topic.id)
+ expect(response[:topics_removed].map(&:eventable_id)).to_not include(old_private_topic.id)
+ end
+
+ it 'handles permissions for synapses added' do
+ new_synapse = nil
+ new_private_synapse = nil
+
+ Timecop.freeze(10.hours.ago) do
+ # visible
+ new_synapse = create(:synapse, permission: 'commons', user: other_user)
+ create(:mapping, map: map, mappable: new_synapse, user: other_user)
+ # not visible
+ new_private_synapse = create(:synapse, permission: 'private', user: other_user)
+ create(:mapping, map: map, mappable: new_private_synapse, user: other_user)
+ end
+ Timecop.return
+
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats]).to eq({
+ synapses_added: 1
+ })
+ expect(response[:synapses_added].map(&:eventable_id)).to include(new_synapse.id)
+ expect(response[:synapses_added].map(&:eventable_id)).to_not include(new_private_synapse.id)
+ end
+
+ it 'handles permissions for synapses removed' do
+ old_synapse = nil
+ old_private_synapse = nil
+ old_synapse_mapping = nil
+ old_private_synapse_mapping = nil
+
+ Timecop.freeze(2.days.ago) do
+ old_synapse = create(:synapse, permission: 'commons', user: other_user)
+ old_synapse_mapping = create(:mapping, map: map, mappable: old_synapse, user: other_user)
+ old_private_synapse = create(:synapse, permission: 'private', user: other_user)
+ old_private_synapse_mapping = create(:mapping, map: map, mappable: old_private_synapse, user: other_user)
+ end
+ Timecop.return
+
+ Timecop.freeze(10.hours.ago) do
+ # visible
+ old_synapse_mapping.updated_by = other_user
+ old_synapse_mapping.destroy
+ # not visible
+ old_private_synapse_mapping.updated_by = other_user
+ old_private_synapse_mapping.destroy
+ end
+ Timecop.return
+
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats]).to eq({
+ synapses_removed: 1
+ })
+ expect(response[:synapses_removed].map(&:eventable_id)).to include(old_synapse.id)
+ expect(response[:synapses_removed].map(&:eventable_id)).to_not include(old_private_synapse.id)
+ end
+
+ describe 'messages in the map chat' do
+ it 'counts messages within the last 24 hours' do
+ create(:message, resource: map, created_at: 6.hours.ago)
+ create(:message, resource: map, created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:messages_sent]).to eq(2)
+ end
+
+ it 'does not count messages outside the last 24 hours' do
+ create(:message, resource: map, created_at: 25.hours.ago)
+ create(:message, resource: map, created_at: 5.hours.ago)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:messages_sent]).to eq(1)
+ end
+
+ it 'does not count messages sent by the person who will receive the data' do
+ create(:message, resource: map, created_at: 5.hours.ago, user: other_user)
+ create(:message, resource: map, created_at: 5.hours.ago, user: email_user)
+ response = MapActivityService.summarize_data(map, email_user)
+ expect(response[:stats][:messages_sent]).to eq(1)
+ end
+ end
+end
diff --git a/webpack.config.js b/webpack.config.js
index 13cf6a65..a207d23c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -5,8 +5,12 @@ const NODE_ENV = process.env.NODE_ENV || 'development'
const plugins = [
new webpack.DefinePlugin({
"process.env.NODE_ENV": `"${NODE_ENV}"`
- })
+ }),
+ new webpack.IgnorePlugin(/^mock-firmata$/), // work around bindings.js error
+ new webpack.ContextReplacementPlugin(/bindings$/, /^$/) // work around bindings.js error
]
+const externals = ["bindings"] // work around bindings.js error
+
if (NODE_ENV === 'production') {
plugins.push(new webpack.optimize.DedupePlugin())
plugins.push(new webpack.optimize.UglifyJsPlugin({
@@ -26,12 +30,13 @@ const devtool = NODE_ENV === 'production' ? undefined : 'cheap-module-eval-sourc
module.exports = {
context: __dirname,
plugins,
+ externals,
devtool,
module: {
- preLoaders: [
- { test: /\.json$/, loader: 'json' }
- ],
loaders: [
+ {
+ test: /\.json$/, loader: 'json-loader'
+ },
{
test: /\.(js|jsx)?$/,
exclude: /node_modules/,
diff --git a/webpack.test.config.js b/webpack.test.config.js
new file mode 100644
index 00000000..518835d4
--- /dev/null
+++ b/webpack.test.config.js
@@ -0,0 +1,5 @@
+const config = require('./webpack.config')
+
+config.target = 'node'
+
+module.exports = config