Compare commits

..

6 commits

Author SHA1 Message Date
Devin Howard 97b17c678e split out topic card uploads scss file 2018-02-05 21:43:26 -08:00
Devin Howard cebf6626ee fix uploader icons 2018-02-05 21:43:26 -08:00
Devin Howard ed589c79c4 unfinished, not rebased, topic card uploads 2018-02-05 21:43:26 -08:00
Devin Howard 3b3cec9fcc rubocop 2018-02-05 21:43:10 -08:00
Devin Howard 0ffdaf200b fix api docs 2018-02-05 21:39:30 -08:00
Devin Howard f2d909e2d7 add normal/API endpoints for attachments 2018-02-05 21:39:30 -08:00
59 changed files with 964 additions and 225 deletions

View file

@ -38,7 +38,7 @@ gem 'uglifier'
group :test do group :test do
gem 'brakeman', require: false gem 'brakeman', require: false
gem 'factory_bot_rails' gem 'factory_girl_rails'
gem 'json-schema' gem 'json-schema'
gem 'rspec-rails' gem 'rspec-rails'
gem 'shoulda-matchers' gem 'shoulda-matchers'

View file

@ -105,10 +105,10 @@ GEM
actionmailer (>= 4.0, < 6) actionmailer (>= 4.0, < 6)
activesupport (>= 4.0, < 6) activesupport (>= 4.0, < 6)
execjs (2.7.0) execjs (2.7.0)
factory_bot (4.8.2) factory_girl (4.8.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
factory_bot_rails (4.8.2) factory_girl_rails (4.8.0)
factory_bot (~> 4.8.2) factory_girl (~> 4.8.0)
railties (>= 3.0.0) railties (>= 3.0.0)
faker (1.8.4) faker (1.8.4)
i18n (~> 0.5) i18n (~> 0.5)
@ -311,7 +311,7 @@ DEPENDENCIES
doorkeeper doorkeeper
dotenv-rails dotenv-rails
exception_notification exception_notification
factory_bot_rails factory_girl_rails
faker faker
httparty httparty
jquery-rails jquery-rails

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="document" viewBox="0 0 8 8">
<path d="M0 0v8h7v-4h-4v-4h-3zm4 0v3h3l-3-3zm-3 2h1v1h-1v-1zm0 2h1v1h-1v-1zm0 2h4v1h-4v-1z" />
</svg>

After

Width:  |  Height:  |  Size: 218 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="file" viewBox="0 0 8 8">
<path d="M0 0v8h7v-4h-4v-4h-3zm4 0v3h3l-3-3z" />
</svg>

After

Width:  |  Height:  |  Size: 168 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="image" viewBox="0 0 8 8">
<path d="M0 0v8h8v-8h-8zm1 1h6v3l-1-1-1 1 2 2v1h-1l-4-4-1 1v-3z" />
</svg>

After

Width:  |  Height:  |  Size: 188 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="musical-note" viewBox="0 0 8 8">
<path d="M8 0c-5 0-6 1-6 1v4.093999999999999c-.154-.054-.327-.094-.5-.094-.828 0-1.5.672-1.5 1.5s.672 1.5 1.5 1.5 1.5-.672 1.5-1.5v-3.969c.732-.226 1.99-.438 4-.5v2.063c-.154-.054-.327-.094-.5-.094-.828 0-1.5.672-1.5 1.5s.672 1.5 1.5 1.5 1.5-.672 1.5-1.5v-5.5z"
/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="question-mark" data-container-transform="translate(2)" viewBox="0 0 8 8">
<path d="M4.469 0c-.854 0-1.48.256-1.875.656s-.54.901-.594 1.281l1 .125c.036-.26.125-.497.313-.688.188-.19.491-.375 1.156-.375.664 0 1.019.163 1.219.344.199.181.281.405.281.656 0 .833-.313 1.063-.813 1.5-.5.438-1.188 1.083-1.188 2.25v.25h1v-.25c0-.833.344-1.063.844-1.5.5-.438 1.156-1.083 1.156-2.25 0-.479-.168-1.02-.594-1.406-.426-.387-1.071-.594-1.906-.594zm-.5 7v1h1v-1h-1z"
/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -13,6 +13,13 @@ Metamaps.ServerData['sounds/MM_sounds.ogg'] = '<%= asset_path 'sounds/MM_sounds.
Metamaps.ServerData['exploremaps_sprite.png'] = '<%= asset_path 'exploremaps_sprite.png' %>' Metamaps.ServerData['exploremaps_sprite.png'] = '<%= asset_path 'exploremaps_sprite.png' %>'
Metamaps.ServerData['map_control_sprite.png'] = '<%= asset_path 'map_control_sprite.png' %>' Metamaps.ServerData['map_control_sprite.png'] = '<%= asset_path 'map_control_sprite.png' %>'
Metamaps.ServerData['user_sprite.png'] = '<%= asset_path 'user_sprite.png' %>' Metamaps.ServerData['user_sprite.png'] = '<%= asset_path 'user_sprite.png' %>'
Metamaps.ServerData.attachmentFileTypeIcons = {
pdf: '<%= asset_path('attachmentFileTypeIcons//open-iconic-document.svg') %>',
text: '<%= asset_path('attachmentFileTypeIcons//open-iconic-file.svg') %>',
image: '<%= asset_path('attachmentFileTypeIcons//open-iconic-image.svg') %>',
audio: '<%= asset_path('attachmentFileTypeIcons//open-iconic-musical-note.svg') %>',
unknown: '<%= asset_path('attachmentFileTypeIcons//open-iconic-question-mark.svg') %>'
}
Metamaps.ServerData.Metacodes = <%= Metacode.all.to_json.gsub(%r[(icon.*?)(\"},)], '\1?purple=stupid\2').html_safe %> Metamaps.ServerData.Metacodes = <%= Metacode.all.to_json.gsub(%r[(icon.*?)(\"},)], '\1?purple=stupid\2').html_safe %>
Metamaps.ServerData.REALTIME_SERVER = '<%= ENV['REALTIME_SERVER'] %>' Metamaps.ServerData.REALTIME_SERVER = '<%= ENV['REALTIME_SERVER'] %>'
Metamaps.ServerData.RAILS_ENV = '<%= ENV['RAILS_ENV'] %>' Metamaps.ServerData.RAILS_ENV = '<%= ENV['RAILS_ENV'] %>'

View file

@ -197,6 +197,7 @@ $mid-gray-opacity: rgba(66, 66, 66, 0.6);
.CardOnGraph .links { .CardOnGraph .links {
position: relative; position: relative;
background-color: #e0e0e0;
z-index: 2; z-index: 2;
.linkItem { .linkItem {
@ -625,156 +626,6 @@ background-color: #E0E0E0;
z-index:100; z-index:100;
} }
#embedlyLinkLoader {
margin: 0 auto;
width: 28px;
}
.CardOnGraph .link-adder {
width:100%;
height:47px;
position: relative;
}
.link-adder a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
margin-left: 40px;
padding-top:9px;
font-size: 16px;
line-height: 16px;
}
#addlink, #addupload {
display: inline-block;
width: 102px;
height: 12px;
text-align: left;
padding: 18px 0 18px 48px;
font-size: 12px;
color: #9e9e9e;
cursor: pointer;
position: relative;
}
#addlink:hover, #addupload:hover {
color: #616161;
}
.attachmentIcon {
background-repeat: no-repeat;
background-position: 0 0;
width: 24px;
height: 24px;
position: absolute;
top: 12px;
left: 12px;
}
#linkIcon {
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
}
#uploadIcon {
background-image: url(<%= asset_data_uri('upload_sprite.png') %>);
}
#addlink:hover #linkIcon, #addupload:hover #uploadIcon {
background-position: 0 -24px;
}
.addLink {
position: relative;
}
#addLinkInput {
height: 32px;
width: 268px;
padding: 8px 16px 8px 16px;
position: relative;
border: none;
line-height: 14px;
}
#addLinkInput input{
padding: 9px 27px 9px 31px;
height: 12px;
width: 210px;
margin: 0 0 0 0;
border: none;
outline: none;
font-size: 12px;
line-height: 12px;
background: white;
color: black;
font-family: 'din-regular', helvetica, sans-serif;
}
#addLinkIcon {
position: absolute;
top: 12px;
left: 20px;
width: 24px;
height: 24px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
pointer-events: none;
z-index: 1;
}
#addLinkReset {
position: absolute;
top: 8px;
right: 15px;
width: 32px;
height: 32px;
cursor: pointer;
float:none;
background-image: url(<%= asset_data_uri('remove.png') %>);
background-repeat: no-repeat;
background-position: center center;
}
.embeds.nonEmbedlyLink {
padding-top: 24px;
}
#embedlyLink {
border-left: 8px solid #CCC;
overflow: hidden;
padding: 8px;
padding-left: 12px;
-moz-box-shadow: 1px 1px 5px 0 #ccc;
-webkit-box-shadow: 1px 1px 5px 0 #ccc;
box-shadow: 1px 1px 5px 0 #ccc;
-moz-border-radius-topright: 5px;
-webkit-border-top-right-radius: 5px;
border-top-right-radius: 5px;
-moz-border-radius-bottomright: 8px;
-webkit-border-bottom-right-radius: 8px;
border-bottom-right-radius: 8px;
margin: 8px;
}
.linkActions {
position: relative;
}
.CardOnGraph .embeds {
position: relative;
overflow: hidden;
}
#linkremove {
background-image: url(<%= asset_data_uri 'remove.png' %>);
background-repeat: no-repeat;
background-position: center center;
width: 24px;
height: 24px;
position: absolute;
top: 3px;
right: 0;
cursor: pointer;
}
.cardSettings { .cardSettings {
position: absolute; position: absolute;
left: 12px; left: 12px;

View file

@ -0,0 +1,254 @@
$attachment_button_size: 32px;
.attachments {
border-top: 1px solid #bdbdbd;
position: relative;
min-height: 3em;
.file {
margin-top: 0.75em;
max-width: 85%;
.filetype-icon {
width: 16px;
height: 16px;
padding: 0.5em;
padding-top: 0;
float: left;
}
}
}
.upload-audio-start,
.upload-file-dropzone,
.upload-photo-dropzone,
.CardOnGraph .attachment-type-chooser > div {
text-align: center;
color: #cccccc;
font-size: 12px;
cursor: pointer;
&:hover {
color: #999999;
}
&.photo-upload > div {
background-image: url(<%= asset_path('upload_icons/CameraIcons.png') %>);
}
&.link-upload > div {
background-image: url(<%= asset_path('upload_icons/LinkIcons.png') %>);
}
&.audio-upload > div {
background-image: url(<%= asset_path('upload_icons/MicIcons.png') %>);
}
&.file-upload > div {
background-image: url(<%= asset_path('upload_icons/CloudIcons.png') %>);
}
}
.photo-upload,
.link-upload,
.audio-upload,
.file-upload {
background-repeat: no-repeat;
background-size: $attachment_button_size $attachment_button_size;
background-position: 0 center;
width: $attachment_button_size;
height: $attachment_button_size;
& > div {
width: $attachment_button_size;
height: $attachment_button_size;
margin: 0 auto;
box-sizing: border-box;
padding-top: $attachment_button_size;
background-size: $attachment_button_size;
&:hover {
background-position: 0 $attachment_button_size;
}
}
}
.upload-audio-start,
.upload-file-dropzone,
.upload-photo-dropzone {
padding-top: 0.75em;
}
.upload-audio-recording {
font-size: small;
color: #aaa;
text-align: center;
}
$recording_button_size: 48px;
.upload-audio-stop {
background-image: url(<%= asset_path('recording-button.png') %>);
background-size: $recording_button_size;
width: $recording_button_size;
height: $recording_button_size;
margin: 0 auto;
cursor: pointer;
}
.CardOnGraph .attachment-type-chooser {
padding-top: .75em;
& > div {
display: inline-block;
width: 25%;
}
}
#embedlyLinkLoader {
margin: 0 auto;
width: 28px;
}
.CardOnGraph .link-chooser {
width:100%;
height:47px;
position: relative;
}
.link-chooser a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
margin-left: 40px;
padding-top:9px;
font-size: 16px;
line-height: 16px;
}
#addlink, #addupload {
display: inline-block;
width: 102px;
height: 12px;
text-align: left;
padding: 18px 0 18px 48px;
font-size: 12px;
color: #9e9e9e;
cursor: pointer;
position: relative;
}
#addlink:hover, #addupload:hover {
color: #616161;
}
.attachmentIcon {
background-repeat: no-repeat;
background-position: 0 0;
width: 24px;
height: 24px;
position: absolute;
top: 12px;
left: 12px;
}
#linkIcon {
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
}
#uploadIcon {
background-image: url(<%= asset_data_uri('upload_sprite.png') %>);
}
#addlink:hover #linkIcon, #addupload:hover #uploadIcon {
background-position: 0 -24px;
}
.addLink {
position: relative;
}
#addLinkInput {
height: 32px;
width: 268px;
padding: 8px 16px 8px 16px;
position: relative;
border: none;
line-height: 14px;
}
#addLinkInput input{
padding: 9px 27px 9px 31px;
height: 12px;
width: 210px;
margin: 0 0 0 0;
border: none;
outline: none;
font-size: 12px;
line-height: 12px;
background: white;
color: black;
font-family: 'din-regular', helvetica, sans-serif;
}
#addLinkIcon {
position: absolute;
top: 12px;
left: 20px;
width: 24px;
height: 24px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
pointer-events: none;
z-index: 1;
}
.attachment-cancel {
position: absolute;
top: 8px;
right: 15px;
width: 32px;
height: 32px;
cursor: pointer;
float:none;
background-image: url(<%= asset_data_uri('remove.png') %>);
background-repeat: no-repeat;
background-position: center center;
}
.embeds.nonEmbedlyLink {
padding-top: 24px;
}
#embedlyLink {
border-left: 8px solid #CCC;
overflow: hidden;
padding: 8px;
padding-left: 12px;
-moz-box-shadow: 1px 1px 5px 0 #ccc;
-webkit-box-shadow: 1px 1px 5px 0 #ccc;
box-shadow: 1px 1px 5px 0 #ccc;
-moz-border-radius-topright: 5px;
-webkit-border-top-right-radius: 5px;
border-top-right-radius: 5px;
-moz-border-radius-bottomright: 8px;
-webkit-border-bottom-right-radius: 8px;
border-bottom-right-radius: 8px;
margin: 8px;
}
.linkActions {
position: relative;
}
.CardOnGraph .embeds {
position: relative;
overflow: hidden;
}
#linkremove {
background-image: url(<%= asset_data_uri 'remove.png' %>);
background-repeat: no-repeat;
background-position: center center;
width: 24px;
height: 24px;
position: absolute;
top: 3px;
right: 0;
cursor: pointer;
}

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Api
module V2
class AttachmentsController < RestfulController
def searchable_columns
[:file]
end
end
end
end

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
class AttachmentsController < ApplicationController
before_action :set_attachment, only: [:destroy]
after_action :verify_authorized
def create
@attachment = Attachment.new(attachment_params)
authorize @attachment
respond_to do |format|
if @attachment.save
format.json { render json: @attachment, status: :created }
else
format.json { render json: @attachment.errors, status: :unprocessable_entity }
end
end
end
def destroy
@attachment.destroy
respond_to do |format|
format.json { head :no_content }
end
end
private
def set_attachment
@attachment = Attachment.find(params[:id])
authorize @attachment
end
def attachment_params
params.require(:attachment).permit(:id, :file, :attachable_id, :attachable_type)
end
end

View file

@ -42,7 +42,7 @@ class UsersController < ApplicationController
correct_pass = @user.valid_password?(params[:current_password]) correct_pass = @user.valid_password?(params[:current_password])
if correct_pass && @user.update_attributes(user_params) if correct_pass && @user.update_attributes(user_params)
update_follow_settings(@user, params[:settings]) update_follow_settings(@user, params[:settings]) if is_tester(@user)
@user.image = nil if params[:remove_image] == '1' @user.image = nil if params[:remove_image] == '1'
@user.save @user.save
sign_in(@user, bypass: true) sign_in(@user, bypass: true)

View file

@ -15,7 +15,9 @@ class Attachment < ApplicationRecord
end end
} }
validates :attachable, presence: true
validates_attachment_content_type :file, content_type: Attachable.allowed_types validates_attachment_content_type :file, content_type: Attachable.allowed_types
validates_attachment_size :file, :in => 0.megabytes..5.megabytes
def image? def image?
Attachable.image_types.include?(file.instance.file_content_type) Attachable.image_types.include?(file.instance.file_content_type)

View file

@ -33,7 +33,8 @@ module Attachable
end end
def audio_types def audio_types
['audio/ogg', 'audio/mp3'] # .ogg files might be labelled as video
['audio/ogg', 'video/ogg', 'audio/mpeg', 'audio/wav', 'video/webm']
end end
def text_types def text_types

View file

@ -69,10 +69,23 @@ class Topic < ApplicationRecord
Pundit.policy_scope(user, maps).map(&:id) Pundit.policy_scope(user, maps).map(&:id)
end end
def attachments_json
attachments.map do |a|
{
id: a.id,
file_name: a.file_file_name,
content_type: a.file_content_type,
file_size: a.file_file_size,
url: a.file.url
}
end
end
def as_json(options = {}) def as_json(options = {})
super(methods: %i[user_name user_image collaborator_ids]) super(methods: %i[user_name user_image collaborator_ids])
.merge(inmaps: inmaps(options[:user]), inmapsLinks: inmaps_links(options[:user]), .merge(inmaps: inmaps(options[:user]), inmapsLinks: inmaps_links(options[:user]),
map_count: map_count(options[:user]), synapse_count: synapse_count(options[:user])) map_count: map_count(options[:user]), synapse_count: synapse_count(options[:user]),
attachments: attachments_json)
end end
def as_rdf def as_rdf

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class AttachmentPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.where(attachable: TopicPolicy::Scope.new(user, Topic).resolve)
end
end
def index?
true
end
def create?
Pundit.policy(user, record.attachable).update?
end
def destroy?
Pundit.policy(user, record.attachable).update?
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Api
module V2
class AttachmentSerializer < ApplicationSerializer
attributes :id,
:file,
:attachable_type,
:attachable_id,
:created_at,
:updated_at
end
end
end

View file

@ -14,7 +14,8 @@ module Api
def self.embeddable def self.embeddable
{ {
user: {}, user: {},
metacode: {} metacode: {},
attachments: {}
} }
end end

View file

@ -7,6 +7,8 @@ Metamaps::Application.routes.draw do
root to: 'main#home', via: :get root to: 'main#home', via: :get
get 'request', to: 'main#requestinvite', as: :request get 'request', to: 'main#requestinvite', as: :request
resources :attachments, only: %i[create destroy], shallow: true
namespace :explore do namespace :explore do
get 'active' get 'active'
get 'featured' get 'featured'
@ -121,6 +123,7 @@ Metamaps::Application.routes.draw do
namespace :api, path: '/api', default: { format: :json } do namespace :api, path: '/api', default: { format: :json } do
namespace :v2, path: '/v2' do namespace :v2, path: '/v2' do
resources :attachments, only: %i[index show]
resources :metacodes, only: %i[index show] resources :metacodes, only: %i[index show]
resources :mappings, only: %i[index create show update destroy] resources :mappings, only: %i[index create show update destroy]
resources :maps, only: %i[index create show update destroy] do resources :maps, only: %i[index create show update destroy] do

View file

@ -26,13 +26,13 @@ At the time of writing, there are four directories in the spec folder. One,
`support`, is for helper functions. `rails_helper.rb` and `spec_helper.rb` are `support`, is for helper functions. `rails_helper.rb` and `spec_helper.rb` are
also for helper functions. also for helper functions.
`factories` is for a gem called [factory-bot][factory_bot]. This gem lets you `factories` is for a gem called [factory-girl][factory-girl]. This gem lets you
use the `create` and `build` functions to quickly create the simplest possible use the `create` and `build` functions to quickly create the simplest possible
valid version of a given model. For instance: valid version of a given model. For instance:
let(:map1) { create :map } let(:map1) { create :map }
let(:alex) { create :user, name: "Alex" } let(:ronald) { create :user, name: "Ronald" }
let(:map2) { create :map, user: alex } let(:map2) { create :map, user: ronald }
As you can see, you can also customize the factories. You can read the full As you can see, you can also customize the factories. You can read the full
documentation at the link above or check the existing specs to see how it works. documentation at the link above or check the existing specs to see how it works.
@ -53,5 +53,5 @@ the added code works. This will help in a few ways:
Happy testing! Happy testing!
[factory-bot]: https://github.com/thoughtbot/factory_bot [factory-girl]: https://github.com/thoughtbot/factory_girl
[rspec-docs]: http://rspec.info [rspec-docs]: http://rspec.info

View file

@ -22,6 +22,7 @@ traits:
searchable: !include traits/searchable.raml searchable: !include traits/searchable.raml
schemas: schemas:
attachment: !include schemas/_attachment.json
map: !include schemas/_map.json map: !include schemas/_map.json
mapping: !include schemas/_mapping.json mapping: !include schemas/_mapping.json
metacode: !include schemas/_metacode.json metacode: !include schemas/_metacode.json
@ -35,6 +36,7 @@ schemas:
# item: !include resourceTypes/item.raml # item: !include resourceTypes/item.raml
# collection: !include resourceTypes/collection.raml # collection: !include resourceTypes/collection.raml
/attachments: !include apis/attachments.raml
/maps: !include apis/maps.raml /maps: !include apis/maps.raml
/mappings: !include apis/mappings.raml /mappings: !include apis/mappings.raml
/metacodes: !include apis/metacodes.raml /metacodes: !include apis/metacodes.raml

View file

@ -0,0 +1,16 @@
get:
is: [ searchable: { searchFields: "file" }, orderable, pageable ]
securedBy: [ null, token, oauth_2_0 ]
responses:
200:
body:
application/json:
example: !include ../examples/attachments.json
/{id}:
get:
securedBy: [ null, token, oauth_2_0 ]
responses:
200:
body:
application/json:
example: !include ../examples/attachment.json

View file

@ -0,0 +1,10 @@
{
"data": {
"id": 1,
"file": "https://example.org/file.png",
"attachable_type": "Topic",
"attachable_id": 187,
"created_at": "2017-03-01T05:48:09.533Z",
"updated_at": "2017-03-01T05:48:09.533Z"
}
}

View file

@ -0,0 +1,28 @@
{
"data": [
{
"id": 1,
"file": "https://example.org/file.png",
"attachable_type": "Topic",
"attachable_id": 187,
"created_at": "2017-03-01T05:48:09.533Z",
"updated_at": "2017-03-01T05:48:09.533Z"
},
{
"id": 2,
"file": "https://example.org/file.docx",
"attachable_type": "Message",
"attachable_id": 1043,
"created_at": "2017-03-01T05:50:19.533Z",
"updated_at": "2017-03-01T05:50:19.533Z"
}
],
"page": {
"current_page": 1,
"next_page": 2,
"prev_page": 0,
"total_pages": 156,
"total_count": 312,
"per": 2
}
}

View file

@ -0,0 +1,34 @@
{
"name": "Attachment",
"type": "object",
"properties": {
"id": {
"$ref": "_id.json"
},
"file": {
"format": "uri",
"type": "string"
},
"attachable_type": {
"pattern": "(Topic|Message)",
"type": "string"
},
"attachable_id": {
"$ref": "_id.json"
},
"created_at": {
"$ref": "_datetimestamp.json"
},
"updated_at": {
"$ref": "_datetimestamp.json"
}
},
"required": [
"id",
"file",
"attachable_type",
"attachable_id",
"created_at",
"updated_at"
]
}

View file

@ -0,0 +1,12 @@
{
"name": "Attachment Envelope",
"type": "object",
"properties": {
"data": {
"$ref": "_attachment.json"
}
},
"required": [
"data"
]
}

View file

@ -0,0 +1,19 @@
{
"name": "Attachments",
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "_attachment.json"
}
},
"page": {
"$ref": "_page.json"
}
},
"required": [
"data",
"page"
]
}

View file

@ -37,12 +37,14 @@ const ReactApp = {
mobileTitle: '', mobileTitle: '',
mobileTitleWidth: 0, mobileTitleWidth: 0,
metacodeSets: [], metacodeSets: [],
attachmentFileTypeIcons: {},
init: function(serverData, openLightbox) { init: function(serverData, openLightbox) {
const self = ReactApp const self = ReactApp
self.serverData = serverData self.serverData = serverData
self.mobileTitle = serverData.mobileTitle self.mobileTitle = serverData.mobileTitle
self.openLightbox = openLightbox self.openLightbox = openLightbox
self.metacodeSets = serverData.metacodeSets self.metacodeSets = serverData.metacodeSets
self.attachmentFileTypeIcons = serverData.attachmentFileTypeIcons
routes = makeRoutes(serverData.ActiveMapper) routes = makeRoutes(serverData.ActiveMapper)
self.resize() self.resize()
window && window.addEventListener('resize', self.resize) window && window.addEventListener('resize', self.resize)
@ -154,10 +156,13 @@ const ReactApp = {
getTopicCardProps: function() { getTopicCardProps: function() {
const self = ReactApp const self = ReactApp
return { return {
openTopic: TopicCard.openTopic,
metacodeSets: self.metacodeSets, metacodeSets: self.metacodeSets,
onTopicFollow: Topic.onTopicFollow,
openTopic: TopicCard.openTopic,
updateTopic: (topic, obj) => topic.save(obj), updateTopic: (topic, obj) => topic.save(obj),
onTopicFollow: Topic.onTopicFollow uploadAttachment: TopicCard.uploadAttachment,
removeAttachment: TopicCard.removeAttachment,
fileTypeIcons: self.attachmentFileTypeIcons
} }
}, },
getContextMenuProps: function() { getContextMenuProps: function() {

View file

@ -1,3 +1,5 @@
/* global $ */
import { ReactApp } from '../GlobalUI' import { ReactApp } from '../GlobalUI'
const TopicCard = { const TopicCard = {
@ -9,6 +11,56 @@ const TopicCard = {
hideCard: function() { hideCard: function() {
TopicCard.openTopic = null TopicCard.openTopic = null
ReactApp.render() ReactApp.render()
},
uploadAttachment: (topic, file) => {
const data = new window.FormData()
data.append('attachment[file]', file)
data.append('attachment[attachable_type]', 'Topic')
data.append('attachment[attachable_id]', topic.id)
return new Promise((resolve, reject) => {
$.ajax({
url: '/attachments',
type: 'POST',
data,
processData: false,
contentType: false,
success: (data) => {
console.log('file upolad success', data)
topic.fetch({ success: () => {
ReactApp.render()
resolve(true)
}})
},
error: (error) => {
console.error(error)
window.alert('File upload failed')
topic.fetch({ success: () => {
ReactApp.render()
resolve(false)
}})
}
})
})
},
removeAttachment: (topic) => {
const attachments = topic.get('attachments')
if (!attachments || attachments.length < 1) {
return
}
$.ajax({
url: `/attachments/${attachments[0].id}`,
type: 'DELETE',
success: () => {
console.log('delete success, syncing topic')
topic.fetch({ success: () => ReactApp.render() })
},
error: error => {
console.error(error)
window.alert('Failed to remove attachment')
topic.fetch({ success: () => ReactApp.render() })
}
})
} }
} }

View file

@ -1,20 +1,101 @@
import React, { Component } from 'react' import React, { Component } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import EmbedlyLink from './EmbedlyLink' import EmbedlyLinkChooser from './EmbedlyLinkChooser'
import EmbedlyCard from './EmbedlyCard'
import FileUploader from './FileUploader'
import PhotoUploader from './PhotoUploader'
import AudioUploader from './AudioUploader'
import FileAttachment from './FileAttachment'
class Attachments extends Component { class Attachments extends Component {
constructor(props) {
super(props)
this.state = this.defaultState
}
defaultState = {
addingPhoto: false,
addingLink: false,
addingAudio: false,
addingFile: false
}
clearState = () => {
this.setState(this.defaultState)
}
// onClick handler for the 4 buttons, which triggers showing the proper uploader
choose = key => () => {
this.setState(Object.assign({}, this.defaultState, { [key]: true }))
}
render = () => { render = () => {
const { topic, authorizedToEdit, updateTopic } = this.props const { topic, authorizedToEdit, updateTopic } = this.props
const link = topic.get('link') const link = topic.get('link')
const attachments = topic.get('attachments')
const file = attachments && attachments.length ? attachments[0] : null
let childComponent
if (link) {
childComponent = (
<EmbedlyCard link={link}
authorizedToEdit={authorizedToEdit}
removeLink={this.clearState}
/>
)
} else if (file) {
childComponent = (
<FileAttachment file={file}
authorizedToEdit={authorizedToEdit}
removeAttachment={this.props.removeAttachment}
fileTypeIcons={this.props.fileTypeIcons}
/>
)
} else if (!authorizedToEdit) {
childComponent = null
} else if (this.state.addingPhoto) {
childComponent = (
<PhotoUploader updateTopic={updateTopic}
uploadAttachment={this.props.uploadAttachment}
cancel={this.clearState}
/>
)
} else if (this.state.addingLink) {
childComponent = (
<EmbedlyLinkChooser updateTopic={updateTopic}
cancel={this.clearState}
/>
)
} else if (this.state.addingAudio) {
childComponent = (
<AudioUploader updateTopic={updateTopic}
uploadAttachment={this.props.uploadAttachment}
cancel={this.clearState}
/>
)
} else if (this.state.addingFile) {
childComponent = (
<FileUploader updateTopic={updateTopic}
uploadAttachment={this.props.uploadAttachment}
cancel={this.clearState}
/>
)
} else {
childComponent = (
<div className="attachment-type-chooser">
<div className="photo-upload"><div onClick={this.choose('addingPhoto')}>Photo</div></div>
<div className="link-upload"><div onClick={this.choose('addingLink')}>Link</div></div>
<div className="audio-upload"><div onClick={this.choose('addingAudio')}>Audio</div></div>
<div className="file-upload"><div onClick={this.choose('addingFile')}>Upload</div></div>
</div>
)
}
return ( return (
<div className="attachments"> <div className="attachments">
<EmbedlyLink topicId={topic.id} {childComponent}
link={link}
authorizedToEdit={authorizedToEdit}
updateTopic={updateTopic}
/>
</div> </div>
) )
} }
@ -23,7 +104,10 @@ class Attachments extends Component {
Attachments.propTypes = { Attachments.propTypes = {
topic: PropTypes.object, // Backbone object topic: PropTypes.object, // Backbone object
authorizedToEdit: PropTypes.bool, authorizedToEdit: PropTypes.bool,
updateTopic: PropTypes.func updateTopic: PropTypes.func,
uploadAttachment: PropTypes.func,
removeAttachment: PropTypes.func,
fileTypeIcons: PropTypes.objectOf(PropTypes.string)
} }
export default Attachments export default Attachments

View file

@ -0,0 +1,81 @@
import React, { Component, PropTypes } from 'react'
import Recorder from 'react-recorder'
class AudioUploader extends Component {
constructor(props) {
super(props)
this.state = {
command: 'none'
}
}
timeLimit30sTimeoutId = null
enforce30sTimeLimit = cmd => {
window.clearTimeout(this.timeLimit30sTimeoutId)
if (cmd === 'start') {
this.timeLimit30sTimeoutId = window.setTimeout(() => {
this.command('stop')()
}, 30000)
}
}
command = cmd => () => {
this.enforce30sTimeLimit(cmd)
this.setState({ command: cmd })
}
onStop = blob => {
const now = new Date()
const date = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}-${now.getHours()}:${now.getMinutes()}`
const filename = `metamaps-recorded-audio-${date}.wav`
const file = new window.File([blob], filename, { lastModifiedDate: now })
this.props.uploadAttachment(file).then(success => {
if (!success) {
this.command('none')
}
})
}
handleRecordingError = () => {
window.alert(`Audio recording failed. Some possible reasons include:
not using an HTTPS connection,
missing microphone,
you haven't allowed your browser access to your microphone,
or you need to reload the page.`)
}
render() {
return (
<div className="audio-uploader">
<Recorder command={this.state.command}
onStop={this.onStop}
onError={this.handleRecordingError}
/>
{this.state.command === 'start' && (
<div className="upload-audio-recording">
<div className="stop upload-audio-stop" onClick={this.command('stop')} />
<div className="upload-audio-recording-text">&nbsp;&nbsp;Recording...</div>
</div>
)}
{this.state.command === 'none' && (
<div className="start upload-audio-start" onClick={this.command('start')}>
Click to record <br />
(max 30 seconds)
</div>
)}
<div className="attachment-cancel" onClick={this.props.cancel} />
</div>
)
}
}
AudioUploader.propTypes = {
uploadAttachment: PropTypes.func,
cancel: PropTypes.func
}
export default AudioUploader

View file

@ -1,4 +1,4 @@
/* global $, embedly */ /* global embedly */
import React, { Component } from 'react' import React, { Component } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
@ -38,29 +38,33 @@ class EmbedlyCard extends Component {
} }
render = () => { render = () => {
const { link } = this.props
const { embedlyLinkLoaded, embedlyLinkStarted, embedlyLinkError } = this.state const { embedlyLinkLoaded, embedlyLinkStarted, embedlyLinkError } = this.state
const notReady = embedlyLinkStarted && !embedlyLinkLoaded && !embedlyLinkError const notReady = embedlyLinkStarted && !embedlyLinkLoaded && !embedlyLinkError
return ( return (
<div> <div className="embeds">
<a style={{ display: notReady ? 'none' : 'block' }} <a style={{ display: notReady ? 'none' : 'block' }}
href={link} href={this.props.link}
id="embedlyLink" id="embedlyLink"
target="_blank" target="_blank"
data-card-description="0" data-card-description="0"
> >
{link} {this.props.link}
</a> </a>
{notReady && <div id="embedlyLinkLoader">loading...</div>} {notReady && <div id="embedlyLinkLoader">loading...</div>}
{this.props.authorizedToEdit && (
<div id="linkremove" onClick={this.props.removeLink} />
)}
</div> </div>
) )
} }
} }
EmbedlyCard.propTypes = { EmbedlyCard.propTypes = {
link: PropTypes.string link: PropTypes.string,
authorizedToEdit: PropTypes.bool,
removeLink: PropTypes.func
} }
export default EmbedlyCard export default EmbedlyCard

View file

@ -1,10 +1,7 @@
/* global embedly */
import React, { Component } from 'react' import React, { Component } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Card from './Card' class EmbedlyLinkChooser extends Component {
class EmbedlyLink extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -13,12 +10,9 @@ class EmbedlyLink extends Component {
} }
} }
removeLink = () => {
this.props.updateTopic({ link: null })
}
resetLink = () => { resetLink = () => {
this.setState({ linkEdit: '' }) this.setState({ linkEdit: '' })
this.props.cancel()
} }
onLinkChangeHandler = e => { onLinkChangeHandler = e => {
@ -35,17 +29,11 @@ class EmbedlyLink extends Component {
} }
render = () => { render = () => {
const { link, authorizedToEdit, topicId } = this.props
const { linkEdit } = this.state const { linkEdit } = this.state
const hasAttachment = !!link
if (!hasAttachment && !authorizedToEdit) return null
return ( return (
<div className={hasAttachment ? 'embeds' : 'link-adder'}> <div className="link-chooser">
<div className="addLink" <div className="addLink">
style={{ display: hasAttachment ? 'none' : 'block' }}
>
<div id="addLinkIcon"></div> <div id="addLinkIcon"></div>
<div id="addLinkInput"> <div id="addLinkInput">
<input ref={input => (this.linkInput = input)} <input ref={input => (this.linkInput = input)}
@ -53,26 +41,17 @@ class EmbedlyLink extends Component {
value={linkEdit} value={linkEdit}
onChange={this.onLinkChangeHandler} onChange={this.onLinkChangeHandler}
onKeyUp={this.onLinkKeyUpHandler}></input> onKeyUp={this.onLinkKeyUpHandler}></input>
{linkEdit && <div id="addLinkReset" onClick={this.resetLink}></div>} <div className="attachment-cancel" onClick={this.resetLink}></div>
</div> </div>
</div> </div>
{link && <Card key={topicId} link={link} />}
{authorizedToEdit && (
<div id="linkremove"
style={{ display: hasAttachment ? 'block' : 'none' }}
onClick={this.removeLink}
/>
)}
</div> </div>
) )
} }
} }
EmbedlyLink.propTypes = { EmbedlyLinkChooser.propTypes = {
topicId: PropTypes.number, updateTopic: PropTypes.func,
link: PropTypes.string, cancel: PropTypes.func
authorizedToEdit: PropTypes.bool,
updateTopic: PropTypes.func
} }
export default EmbedlyLink export default EmbedlyLinkChooser

View file

@ -0,0 +1,57 @@
import React, { Component, PropTypes } from 'react'
class FileAttachment extends Component {
getFileType = contentType => {
if (contentType === 'text/plain') {
return 'text'
} else if (contentType === 'application/pdf') {
return 'pdf'
} else if (contentType.match(/^image\//)) {
return 'image'
} else if (contentType.match(/^audio\//) ||
contentType === 'video/ogg' ||
contentType === 'video/webm') {
return 'audio'
} else {
return 'unknown'
}
}
getFileIcon = file => {
const type = this.getFileType(file.content_type)
if (this.props.fileTypeIcons[type]) {
return this.props.fileTypeIcons[type]
} else {
return this.props.fileTypeIcons.unknown
}
}
render() {
const { file } = this.props
return (
<div className={`file ${this.getFileType(file.content_type)}-file-type`}
style={{ clear: 'both' }}
>
<a href={file.url} target="_blank">
<img src={this.getFileIcon(file)} className="filetype-icon" />
{file.file_name}
</a>
<div className="attachment-cancel" onClick={this.props.removeAttachment} />
</div>
)
}
}
FileAttachment.propTypes = {
file: PropTypes.shape({
content_type: PropTypes.string,
file_name: PropTypes.string,
url: PropTypes.string
}),
authorizedToEdit: PropTypes.bool,
removeAttachment: PropTypes.func,
fileTypeIcons: PropTypes.objectOf(PropTypes.string)
}
export default FileAttachment

View file

@ -0,0 +1,34 @@
import React, { Component, PropTypes } from 'react'
import Dropzone from 'react-dropzone'
class FileUploader extends Component {
handleFileUpload = (acceptedFiles, rejectedFiles) => {
if (acceptedFiles.length >= 1) {
this.props.uploadAttachment(acceptedFiles[0])
} else {
window.alert('File upload failed, please try again.')
}
}
render() {
return (
<div className="upload-file">
<Dropzone className="upload-file-dropzone"
onDrop={this.handleFileUpload}
>
Drag file here <br />
(maximum 5mb)
</Dropzone>
<div className="attachment-cancel" onClick={this.props.cancel} />
</div>
)
}
}
FileUploader.propTypes = {
updateTopic: PropTypes.func,
uploadAttachment: PropTypes.func,
cancel: PropTypes.func
}
export default FileUploader

View file

@ -0,0 +1,34 @@
import React, { Component, PropTypes } from 'react'
import Dropzone from 'react-dropzone'
class PhotoUploader extends Component {
handleFileUpload = (acceptedFiles, rejectedFiles) => {
if (acceptedFiles.length >= 1) {
this.props.uploadAttachment(acceptedFiles[0])
} else {
window.alert('File upload failed, please try again.')
}
}
render() {
return (
<div className="upload-photo">
<Dropzone className="upload-photo-dropzone"
onDrop={this.handleFileUpload}
>
Drag photo here <br />
or click to upload
</Dropzone>
<div className="attachment-cancel" onClick={this.props.cancel} />
</div>
)
}
}
PhotoUploader.propTypes = {
updateTopic: PropTypes.func,
uploadAttachment: PropTypes.func,
cancel: PropTypes.func
}
export default PhotoUploader

View file

@ -10,12 +10,17 @@ import Info from './Info'
class ReactTopicCard extends Component { class ReactTopicCard extends Component {
render = () => { render = () => {
const { currentUser, onTopicFollow, updateTopic } = this.props const {
currentUser, onTopicFollow, updateTopic, uploadAttachment,
removeAttachment
} = this.props
const topic = this.props.openTopic const topic = this.props.openTopic
if (!topic) return null if (!topic) return null
const wrappedUpdateTopic = obj => updateTopic(topic, obj) const wrappedUpdateTopic = obj => updateTopic(topic, obj)
const wrappedUploadAttachment = file => uploadAttachment(topic, file)
const wrappedRemoveAttachment = () => removeAttachment(topic)
const authorizedToEdit = topic.authorizeToEdit(currentUser) const authorizedToEdit = topic.authorizeToEdit(currentUser)
const hasAttachment = topic.get('link') && topic.get('link') !== '' const hasAttachment = topic.get('link') && topic.get('link') !== ''
@ -48,9 +53,13 @@ class ReactTopicCard extends Component {
authorizedToEdit={authorizedToEdit} authorizedToEdit={authorizedToEdit}
onChange={wrappedUpdateTopic} onChange={wrappedUpdateTopic}
/> />
<Attachments topic={topic} <Attachments key={topic.id}
topic={topic}
authorizedToEdit={authorizedToEdit} authorizedToEdit={authorizedToEdit}
updateTopic={wrappedUpdateTopic} updateTopic={wrappedUpdateTopic}
uploadAttachment={wrappedUploadAttachment}
removeAttachment={wrappedRemoveAttachment}
fileTypeIcons={this.props.fileTypeIcons}
/> />
<Info topic={topic} /> <Info topic={topic} />
<div className='clearfloat' /> <div className='clearfloat' />
@ -75,7 +84,9 @@ ReactTopicCard.propTypes = {
name: PropTypes.string name: PropTypes.string
})) }))
})), })),
redrawCanvas: PropTypes.func redrawCanvas: PropTypes.func,
uploadAttachment: PropTypes.func,
fileTypeIcons: PropTypes.objectOf(PropTypes.string)
} }
export default ReactTopicCard export default ReactTopicCard

View file

@ -49,6 +49,7 @@
"react-draggable": "3.0.3", "react-draggable": "3.0.3",
"react-dropzone": "4.1.2", "react-dropzone": "4.1.2",
"react-onclickoutside": "6.5.0", "react-onclickoutside": "6.5.0",
"react-recorder": "1.0.0",
"react-router": "3.0.5", "react-router": "3.0.5",
"redux": "3.7.2", "redux": "3.7.2",
"riek": "1.1.0", "riek": "1.1.0",

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'topics API', type: :request do
let(:user) { create(:user, admin: true) }
let(:token) { create(:token, user: user).token }
let(:attachment) { create(:attachment) }
it 'GET /api/v2/attachments' do
create_list(:attachment, 5)
get '/api/v2/attachments', params: { access_token: token }
expect(response).to have_http_status(:success)
expect(response).to match_json_schema(:attachments)
expect(JSON.parse(response.body)['data'].count).to eq 5
end
it 'GET /api/v2/attachments/:id' do
get "/api/v2/attachments/#{attachment.id}"
expect(response).to have_http_status(:success)
expect(response).to match_json_schema(:attachment)
expect(JSON.parse(response.body)['data']['id']).to eq attachment.id
end
context 'RAML example' do
let(:resource) { get_json_example(:attachment) }
let(:collection) { get_json_example(:attachments) }
it 'resource matches schema' do
expect(resource).to match_json_schema(:attachment)
end
it 'collection matches schema' do
expect(collection).to match_json_schema(:attachments)
end
end
end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :access_request do factory :access_request do
map map
user user

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
FactoryGirl.define do
factory :attachment do
association :attachable, factory: :topic
end
end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :mapping do factory :mapping do
xloc 0 xloc 0
yloc 0 yloc 0

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :map do factory :map do
sequence(:name) { |n| "Cool Map ##{n}" } sequence(:name) { |n| "Cool Map ##{n}" }
permission :commons permission :commons

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :message do factory :message do
association :resource, factory: :map association :resource, factory: :map
user user

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :metacode do factory :metacode do
sequence(:name) { |n| "Cool Metacode ##{n}" } sequence(:name) { |n| "Cool Metacode ##{n}" }
manual_icon 'https://images.com/image.png' manual_icon 'https://images.com/image.png'

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :star do factory :star do
end end
end end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :synapse do factory :synapse do
sequence(:desc) { |n| "Cool synapse ##{n}" } sequence(:desc) { |n| "Cool synapse ##{n}" }
category :'from-to' category :'from-to'

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :token do factory :token do
user user
description '' description ''

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :topic do factory :topic do
user user
association :updated_by, factory: :user association :updated_by, factory: :user

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryGirl.define do
factory :user_map do factory :user_map do
map map
user user

View file

@ -10,7 +10,7 @@
# have actual codes, you'll need to specify one simple_user and then you # have actual codes, you'll need to specify one simple_user and then you
# can specify other :code_user users based on the pre-existing user's code. # can specify other :code_user users based on the pre-existing user's code.
FactoryBot.define do FactoryGirl.define do
factory :code_user, class: User do factory :code_user, class: User do
sequence(:name) { |n| "Cool User ##{n}" } sequence(:name) { |n| "Cool User ##{n}" }
sequence(:email) { |n| "cooluser#{n}@cooldomain.com" } sequence(:email) { |n| "cooluser#{n}@cooldomain.com" }

View file

@ -23,7 +23,7 @@ RSpec.describe MapPolicy, type: :policy do
context 'private' do context 'private' do
let(:map) { create(:map, permission: :private) } let(:map) { create(:map, permission: :private) }
permissions :show?, :create?, :update?, :destroy? do permissions :show?, :create?, :update?, :destroy? do
it 'permits access' do it 'denies access' do
expect(subject).to_not permit(nil, map) expect(subject).to_not permit(nil, map)
end end
end end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# lets you type create(:user) instead of FactoryBot.create(:user) # lets you type create(:user) instead of FactoryGirl.create(:user)
RSpec.configure do |config| RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods config.include FactoryGirl::Syntax::Methods
end end