diff --git a/.eslintrc b/.eslintrc index 62ba89fda..275e8ed71 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,8 @@ "env": { "browser": true, "jasmine": true, - "jquery": true + "jquery": true, + "es6": true }, "globals": { @@ -96,7 +97,7 @@ "no-catch-shadow": 0, "no-class-assign": 2, "no-cond-assign": 2, - "no-confusing-arrow": 2, + "no-confusing-arrow": [2, {"allowParens": true}], "no-console": 2, "no-const-assign": 2, "no-constant-condition": 2, diff --git a/Gemfile b/Gemfile index 8234f47be..1abb30eb6 100644 --- a/Gemfile +++ b/Gemfile @@ -89,6 +89,7 @@ gem "entypo-rails", "3.0.0" # JavaScript +gem "sprockets-es6", "0.9.2" gem "handlebars_assets", "0.23.2" gem "jquery-rails", "4.3.1" gem "js-routes", "1.4.1" @@ -110,6 +111,7 @@ source "https://rails-assets.org" do gem "rails-assets-backbone", "1.3.3" gem "rails-assets-bootstrap-markdown", "2.10.0" gem "rails-assets-corejs-typeahead", "1.1.1" + gem "rails-assets-cropperjs", "1.2.1" gem "rails-assets-fine-uploader", "5.13.0" # jQuery plugins diff --git a/Gemfile.lock b/Gemfile.lock index 625eff57f..d69e47b08 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -62,6 +62,10 @@ GEM attr_required (1.0.1) autoprefixer-rails (7.1.4.1) execjs + babel-source (5.8.35) + babel-transpiler (0.7.0) + babel-source (>= 4.0, < 6) + execjs (~> 2.0) bcrypt (3.1.11) bindata (2.4.1) bootstrap-sass (3.3.7) @@ -516,6 +520,7 @@ GEM rails-assets-bootstrap (~> 3) rails-assets-corejs-typeahead (1.1.1) rails-assets-jquery (>= 1.11) + rails-assets-cropperjs (1.2.1) rails-assets-diaspora_jsxc (0.1.5.develop.7) rails-assets-emojione (~> 2.0.1) rails-assets-favico.js (>= 0.3.10, < 0.4) @@ -670,6 +675,10 @@ GEM sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) + sprockets-es6 (0.9.2) + babel-source (>= 5.8.11) + babel-transpiler + sprockets (>= 3.0.0) sprockets-rails (3.2.1) actionpack (>= 4.0) activesupport (>= 4.0) @@ -848,6 +857,7 @@ DEPENDENCIES rails-assets-blueimp-gallery (= 2.27.0)! rails-assets-bootstrap-markdown (= 2.10.0)! rails-assets-corejs-typeahead (= 1.1.1)! + rails-assets-cropperjs (= 1.2.1)! rails-assets-diaspora_jsxc (= 0.1.5.develop.7)! rails-assets-fine-uploader (= 5.13.0)! rails-assets-highlightjs (= 9.12.0)! @@ -888,6 +898,7 @@ DEPENDENCIES spring (= 2.0.2) spring-commands-cucumber (= 1.0.1) spring-commands-rspec (= 1.0.4) + sprockets-es6 (= 0.9.2) sprockets-rails (= 3.2.1) string-direction (= 1.2.0) timecop (= 0.9.1) @@ -904,4 +915,4 @@ DEPENDENCIES will_paginate (= 3.1.6) BUNDLED WITH - 1.15.4 + 1.16.1 diff --git a/app/assets/javascripts/helpers/profile_photo_uploader.es6 b/app/assets/javascripts/helpers/profile_photo_uploader.es6 new file mode 100644 index 000000000..5524adf2b --- /dev/null +++ b/app/assets/javascripts/helpers/profile_photo_uploader.es6 @@ -0,0 +1,252 @@ +// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later + +Diaspora.ProfilePhotoUploader = class { + /** + * Initializes a new instance of ProfilePhotoUploader + */ + constructor() { + // get several elements we will use a few times + this.fileInput = document.querySelector("#file-upload"); + this.picture = document.querySelector("#profile_photo_upload .avatar"); + this.info = document.querySelector("#fileInfo"); + this.cropContainer = document.querySelector(".crop-container"); + this.spinner = document.querySelector("#file-upload-spinner"); + + /** + * Creates a button + * @param {string} icon - The entypo icon class. + * @param {function} onClick - Is called when button has been clicked. + */ + this.createButton = (icon, onClick) => + ($(``) + .on("click", onClick)); + + /** + * Shows a message using flash messages or alert for mobile. + * @param {string} type - The type of the message, e.g. "error" or "success". + * @param text - The text to display. + */ + this.showMessage = (type, text) => (app.flashMessages ? app.flashMessages[type](text) : alert(text)); + + this.initFineUploader(); + } + + /** + * Initializes the fine uploader component + */ + initFineUploader() { + this.fineUploader = new qq.FineUploaderBasic({ + element: this.fileInput, + validation: { + allowedExtensions: ["jpg", "jpeg", "png"] + }, + request: { + endpoint: Routes.photos(), + params: { + /* eslint-disable camelcase */ + authenticity_token: $("meta[name='csrf-token']").attr("content"), + /* eslint-enable camelcase */ + photo: {"pending": true, "aspect_ids": "all", "set_profile_photo": true} + } + }, + button: this.fileInput, + autoUpload: false, + + messages: { + typeError: Diaspora.I18n.t("photo_uploader.invalid_ext"), + sizeError: Diaspora.I18n.t("photo_uploader.size_error"), + emptyError: Diaspora.I18n.t("photo_uploader.empty") + }, + + callbacks: { + onProgress: (id, fileName, loaded, total) => { + (this.info.innerText = `${fileName} ${Math.round(loaded / total * 100)}%`); + }, + onSubmit: (id, name) => this.onPictureSelected(id, name), + onComplete: (id, name, responseJSON) => this.onUploadCompleted(id, name, responseJSON), + onError: (id, name) => this.showMessage("error", Diaspora.I18n.t("photo_uploader.error", {file: name})) + }, + + text: { + fileInputTitle: "" + }, + + scaling: { + sendOriginal: false, + + sizes: [ + {maxSize: 1600} + ] + } + }); + } + + /** + * Called when a picture from user's device has been selected. + * @param {number} id - The current file's id. + * @param {string} name - The current file's name. + */ + onPictureSelected(id, name) { + this.setLoading(true); + this.fileName = name; + const file = this.fileInput.querySelector("input").files[0]; + + // ensure browser's file reader support + if (FileReader && file) { + const fileReader = new FileReader(); + fileReader.onload = () => this.initCropper(fileReader.result); + fileReader.readAsDataURL(file); + } else { + this.setLoading(false); + } + } + + /** + * Initializes the cropper and all controls. + * @param {object|string} imageData - The base64 image data + */ + initCropper(imageData) { + // cache the current picture source if the user cancels + this.previousPicture = this.picture.getAttribute("src"); + + this.mimeType = imageData.split(";base64")[0].substring(5); + + this.picture.onload = () => { + // set the preferred size style of the cropper based on picture orientation + const isPortrait = this.picture.naturalHeight > this.picture.naturalWidth; + this.picture.setAttribute("style", (isPortrait ? "max-height:600px;max-width:none;" : "max-width:600px;")); + this.buildControls(); + + this.setLoading(false); + + // eslint-disable-next-line no-undef + this.cropper = new Cropper(this.picture, { + aspectRatio: 1, + zoomable: false, + autoCropArea: 1, + preview: ".preview" + }); + }; + this.picture.setAttribute("src", imageData); + } + + /** + * Creates image manipulation controls and previews. + */ + buildControls() { + this.controls = { + rotateLeft: this.createButton("ccw", () => this.cropper.rotate(-45)), + rotateRight: this.createButton("cw", () => this.cropper.rotate(45)), + reset: this.createButton("cycle", () => this.cropper.reset()), + accept: this.createButton("check", () => this.cropImage()), + cancel: this.createButton("trash", () => this.cancel()) + }; + + this.controlRow = $("
").appendTo(this.cropContainer); + + // rotation buttons on the left + this.controlRow.append($("
").append([ + this.controls.rotateLeft, + this.controls.rotateRight + ])); + + // preview images in the middle + this.controlRow.append("
"); + + // main buttons on the right + this.controlRow.append($("
").append([ + this.controls.reset, + this.controls.cancel, + this.controls.accept + ])); + } + + /** + * Called when the user clicked accept button. Sets file data and triggers file upload. + */ + cropImage() { + const canvas = this.cropper.getCroppedCanvas(); + + // replace the stored file with the new canvas + this.fineUploader.clearStoredFiles(); + this.fineUploader.addFiles([{ + canvas: canvas, + name: this.fileName, + quality: 100, + type: this.mimeType + }]); + + // reset all controls + this.cancel(); + this.picture.setAttribute("src", canvas.toDataURL(this.mimeType)); + + // finally start uploading + this.setLoading(true); + this.fineUploader.uploadStoredFiles(); + } + + /** + * Is called after the file upload has been completed and the profile photo changed. + * @param {number} id - The current file's id. + * @param {string} fileName - The current file's name. + * @param {object} responseJSON - The server's json response. + */ + onUploadCompleted(id, fileName, responseJSON) { + this.setLoading(false); + this.fileInput.classList.remove("hidden"); + + if (responseJSON.data !== undefined) { + /* flash message prompt */ + this.showMessage("success", Diaspora.I18n.t("photo_uploader.looking_good")); + + this.info.innerText = Diaspora.I18n.t("photo_uploader.completed", {"file": fileName}); + + const photoId = responseJSON.data.photo.id; + const url = responseJSON.data.photo.unprocessed_image.url; + const oldPhoto = $("#photo_id"); + if (oldPhoto.length === 0) { + $("#update_profile_form") + .prepend(``); + } else { + oldPhoto.val(photoId); + } + + this.picture.setAttribute("src", url); + $(`.avatar[alt="${gon.user.diaspora_id}"]`).attr("src", url); + } else { + this.cancel(); + } + } + + /** + * Toggles loading state by hiding or showing several elements + * @param {boolean} loading - True if loading state should be enabled. + */ + setLoading(loading) { + if (loading) { + this.fileInput.classList.add("hidden"); + this.picture.classList.add("hidden"); + this.spinner.classList.remove("hidden"); + } else { + this.picture.classList.remove("hidden"); + this.spinner.classList.add("hidden"); + } + } + + /** + * Destroys the cropper and resets all elements to initial state. + */ + cancel() { + this.cropper.destroy(); + this.picture.onload = null; + this.picture.setAttribute("style", ""); + this.picture.setAttribute("src", this.previousPicture); + this.controlRow.remove(); + this.fileInput.classList.remove("hidden"); + this.info.innerText = ""; + + this.mimeType = null; + this.name = null; + } +}; +// @license-end diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a59460b96..a560aa0c7 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -44,3 +44,4 @@ //= require bootstrap-markdown/bootstrap-markdown //= require helpers/markdown_editor //= require jquery.are-you-sure +//= require cropperjs/dist/cropper.js diff --git a/app/assets/javascripts/mobile/mobile.js b/app/assets/javascripts/mobile/mobile.js index 0bb660573..7d69a4a47 100644 --- a/app/assets/javascripts/mobile/mobile.js +++ b/app/assets/javascripts/mobile/mobile.js @@ -16,7 +16,6 @@ //= require bootstrap //= require diaspora //= require helpers/i18n -//= require helpers/profile_photo_uploader //= require helpers/tags_autocomplete //= require bootstrap-markdown/bootstrap-markdown //= require helpers/markdown_editor @@ -32,4 +31,5 @@ //= require mobile/mobile_conversations //= require mobile/mobile_post_actions //= require mobile/mobile_drawer +//= require mobile/mobile_profile_photo_uploader // @license-end diff --git a/app/assets/javascripts/helpers/profile_photo_uploader.js b/app/assets/javascripts/mobile/mobile_profile_photo_uploader.js similarity index 100% rename from app/assets/javascripts/helpers/profile_photo_uploader.js rename to app/assets/javascripts/mobile/mobile_profile_photo_uploader.js diff --git a/app/assets/stylesheets/_application.scss b/app/assets/stylesheets/_application.scss index 169bf1e1c..34bf5292b 100644 --- a/app/assets/stylesheets/_application.scss +++ b/app/assets/stylesheets/_application.scss @@ -42,6 +42,7 @@ // profile and settings pages @import 'settings'; +@import 'cropperjs/dist/cropper'; // new SPV @import 'header'; diff --git a/app/assets/stylesheets/getting-started.scss b/app/assets/stylesheets/getting-started.scss index 12c4931a1..725ad479d 100644 --- a/app/assets/stylesheets/getting-started.scss +++ b/app/assets/stylesheets/getting-started.scss @@ -1,9 +1,4 @@ #hello-there { - #profile_photo_upload .avatar { - max-height: 200px; - max-width: 200px; - } - .well .avatar { min-width: 50px; } diff --git a/app/assets/stylesheets/mobile/mobile.scss b/app/assets/stylesheets/mobile/mobile.scss index 6447df013..60c05cf1e 100644 --- a/app/assets/stylesheets/mobile/mobile.scss +++ b/app/assets/stylesheets/mobile/mobile.scss @@ -631,6 +631,12 @@ h1.session { } } } + + .spinner { + height: 50px; + margin-top: 10px; + width: 50px; + } } #birth-date { diff --git a/app/assets/stylesheets/settings.scss b/app/assets/stylesheets/settings.scss index 11cb5b13f..3dc79ce25 100644 --- a/app/assets/stylesheets/settings.scss +++ b/app/assets/stylesheets/settings.scss @@ -13,6 +13,7 @@ padding-right: 10px; } } + // scss-lint:enable SelectorFormat .enclosed-checkbox label { @@ -28,9 +29,31 @@ max-width: 200px; width: auto; } + + .crop-container { + .controls { + display: flex; + justify-content: space-between; + margin-top: 10px; + } + } + + .preview { + border-radius: 4px; + height: 50px; + overflow: hidden; + width: 50px; + } + + .spinner { + height: 50px; + width: 50px; + } } -.settings-visibility { margin-left: 10px; } +.settings-visibility { + margin-left: 10px; +} .page-profiles.action-edit textarea { max-width: 100%; diff --git a/app/controllers/photos_controller.rb b/app/controllers/photos_controller.rb index 8ebc0908c..5674d888c 100644 --- a/app/controllers/photos_controller.rb +++ b/app/controllers/photos_controller.rb @@ -5,14 +5,14 @@ # the COPYRIGHT file. class PhotosController < ApplicationController - before_action :authenticate_user!, except: %i(show index) + before_action :authenticate_user!, except: %i[show index] respond_to :html, :json def show @photo = if user_signed_in? - current_user.photos_from(Person.find_by_guid(params[:person_id])).where(id: params[:id]).first - else - Photo.where(id: params[:id], public: true).first + current_user.photos_from(Person.find_by(guid: params[:person_id])).where(id: params[:id]).first + else + Photo.where(id: params[:id], public: true).first end raise ActiveRecord::RecordNotFound unless @photo @@ -20,7 +20,7 @@ class PhotosController < ApplicationController def index @post_type = :photos - @person = Person.find_by_guid(params[:person_id]) + @person = Person.find_by(guid: params[:person_id]) authenticate_user! if @person.try(:remote?) && !user_signed_in? @presenter = PersonPresenter.new(@person, current_user) @@ -35,10 +35,10 @@ class PhotosController < ApplicationController render "people/show", layout: "with_header" end format.mobile { render "people/show" } - format.json{ render_for_api :backbone, :json => @posts, :root => :photos } + format.json { render_for_api :backbone, json: @posts, root: :photos } end else - flash[:error] = I18n.t 'people.show.does_not_exist' + flash[:error] = I18n.t "people.show.does_not_exist" redirect_to people_path end end @@ -51,21 +51,23 @@ class PhotosController < ApplicationController def make_profile_photo author_id = current_user.person_id - @photo = Photo.where(:id => params[:photo_id], :author_id => author_id).first + @photo = Photo.where(id: params[:photo_id], author_id: author_id).first if @photo - profile_hash = {:image_url => @photo.url(:thumb_large), - :image_url_medium => @photo.url(:thumb_medium), - :image_url_small => @photo.url(:thumb_small)} + profile_hash = {image_url: @photo.url(:thumb_large), + image_url_medium: @photo.url(:thumb_medium), + image_url_small: @photo.url(:thumb_small)} if current_user.update_profile(profile_hash) respond_to do |format| - format.js{ render :json => { :photo_id => @photo.id, - :image_url => @photo.url(:thumb_large), - :image_url_medium => @photo.url(:thumb_medium), - :image_url_small => @photo.url(:thumb_small), - :author_id => author_id}, - :status => 201} + format.js { + render json: {photo_id: @photo.id, + image_url: @photo.url(:thumb_large), + image_url_medium: @photo.url(:thumb_medium), + image_url_small: @photo.url(:thumb_small), + author_id: author_id}, + status: 201 + } end else head :unprocessable_entity @@ -76,7 +78,7 @@ class PhotosController < ApplicationController end def destroy - photo = current_user.photos.where(:id => params[:id]).first + photo = current_user.photos.where(id: params[:id]).first if photo current_user.retract(photo) @@ -84,16 +86,16 @@ class PhotosController < ApplicationController respond_to do |format| format.json { head :no_content } format.html do - flash[:notice] = I18n.t 'photos.destroy.notice' - if StatusMessage.find_by_guid(photo.status_message_guid) - respond_with photo, :location => post_path(photo.status_message) + flash[:notice] = I18n.t "photos.destroy.notice" + if StatusMessage.find_by(guid: photo.status_message_guid) + respond_with photo, location: post_path(photo.status_message) else - respond_with photo, :location => person_photos_path(current_user.person) + respond_with photo, location: person_photos_path(current_user.person) end end end else - respond_with photo, :location => person_photos_path(current_user.person) + respond_with photo, location: person_photos_path(current_user.person) end end @@ -105,23 +107,27 @@ class PhotosController < ApplicationController def file_handler(params) # For XHR file uploads, request.params[:qqfile] will be the path to the temporary file - # For regular form uploads (such as those made by Opera), request.params[:qqfile] will be an UploadedFile which can be returned unaltered. - if not request.params[:qqfile].is_a?(String) - params[:qqfile] + # For regular form uploads (such as those made by Opera), request.params[:qqfile] will be an + # UploadedFile which can be returned unaltered. + if !request.params[:qqfile].is_a?(String) + qqfile = params[:qqfile] + # Cropped or manipulated files have their real filename only in qqfilename. Take care of this. + qqfile.original_filename = params[:qqfilename] if qqfile.original_filename == "blob" + qqfile else ######################## dealing with local files ############# # get file name file_name = params[:qqfile] # get file content type - att_content_type = (request.content_type.to_s == "") ? "application/octet-stream" : request.content_type.to_s + att_content_type = request.content_type.to_s == "" ? "application/octet-stream" : request.content_type.to_s # create tempora##l file - file = Tempfile.new(file_name, {:encoding => 'BINARY'}) + file = Tempfile.new(file_name, encoding: "BINARY") # put data into this file from raw post request - file.print request.raw_post.force_encoding('BINARY') + file.print request.raw_post.force_encoding("BINARY") # create several required methods for this temporal file - Tempfile.send(:define_method, "content_type") {return att_content_type} - Tempfile.send(:define_method, "original_filename") {return file_name} + Tempfile.send(:define_method, "content_type") { return att_content_type } + Tempfile.send(:define_method, "original_filename") { return file_name } file end end @@ -149,36 +155,35 @@ class PhotosController < ApplicationController end if photo_params[:set_profile_photo] - profile_params = {:image_url => @photo.url(:thumb_large), - :image_url_medium => @photo.url(:thumb_medium), - :image_url_small => @photo.url(:thumb_small)} + profile_params = {image_url: @photo.url(:thumb_large), + image_url_medium: @photo.url(:thumb_medium), + image_url_small: @photo.url(:thumb_small)} current_user.update_profile(profile_params) end respond_to do |format| - format.json{ render(:layout => false , :json => {"success" => true, "data" => @photo}.to_json )} - format.html{ render(:layout => false , :json => {"success" => true, "data" => @photo}.to_json )} + format.json { render(layout: false, json: {"success" => true, "data" => @photo}.to_json) } + format.html { render(layout: false, json: {"success" => true, "data" => @photo}.to_json) } end else - respond_with @photo, :location => photos_path, :error => message + respond_with @photo, location: photos_path, error: message end end def rescuing_photo_errors - begin - yield - rescue TypeError - message = I18n.t 'photos.create.type_error' - respond_with @photo, :location => photos_path, :error => message + yield + rescue TypeError + return_photo_error I18n.t "photos.create.type_error" + rescue CarrierWave::IntegrityError + return_photo_error I18n.t "photos.create.integrity_error" + rescue RuntimeError + return_photo_error I18n.t "photos.create.runtime_error" + end - rescue CarrierWave::IntegrityError - message = I18n.t 'photos.create.integrity_error' - respond_with @photo, :location => photos_path, :error => message - - rescue RuntimeError => e - message = I18n.t 'photos.create.runtime_error' - respond_with @photo, :location => photos_path, :error => message - raise e + def return_photo_error(message) + respond_to do |format| + format.json { render(layout: false, json: {"success" => false, "error" => message}.to_json) } + format.html { render(layout: false, json: {"success" => false, "error" => message}.to_json) } end end end diff --git a/app/views/photos/_new_profile_photo.haml b/app/views/photos/_new_profile_photo.haml index f5cb61659..2b24f2b2e 100644 --- a/app/views/photos/_new_profile_photo.haml +++ b/app/views/photos/_new_profile_photo.haml @@ -3,14 +3,15 @@ -# the COPYRIGHT file. .profile-photo-upload#profile_photo_upload - = owner_image_tag(:thumb_large) + .crop-container + = owner_image_tag(:thumb_large) .small-horizontal-spacer .clearfix .text-center #file-upload.btn.btn-primary =t('.upload') - = image_tag('mobile-spinner.gif', :class => 'hidden', :style => "z-index:-1", :id => 'file-upload-spinner') + .spinner.hidden#file-upload-spinner %p #fileInfo diff --git a/features/desktop/edits_profile.feature b/features/desktop/edits_profile.feature index bf68ed972..90e7b06bb 100644 --- a/features/desktop/edits_profile.feature +++ b/features/desktop/edits_profile.feature @@ -44,12 +44,31 @@ Feature: editing your profile And I press "update_profile" Then I should see "#kamino" within "ul#as-selections-tags" And I should see "#starwars" within "ul#as-selections-tags" + And I should see a ".crop-container" within "#profile_photo_upload" And the "#profile_public_details" bootstrap-switch should be on When I attach the file "spec/fixtures/bad_urls.txt" to "qqfile" within "#file-upload" Then I should see a flash message indicating failure When I attach the file "spec/fixtures/button.png" to hidden "qqfile" within "#file-upload" + Then I should see a ".cropper-container" within ".crop-container" + And I should see a ".controls" within ".crop-container" + And I should see a ".preview" within ".controls" + And I should see 2 ".btn" within ".buttons-left" + And I should see 3 ".btn" within ".buttons-right" + + When I press the 2nd ".btn" within ".buttons-right" + Then I should see a ".avatar" within ".crop-container" + But I should not see a ".cropper-container" within ".crop-container" + + When I attach the file "spec/fixtures/button.png" to hidden "qqfile" within "#file-upload" + Then I should see a ".cropper-container" within ".crop-container" + And I should see a ".controls" within ".crop-container" + And I should see a ".preview" within ".controls" + And I should see 2 ".btn" within ".buttons-left" + And I should see 3 ".btn" within ".buttons-right" + + When I press the 3rd ".btn" within ".buttons-right" Then I should see "button.png completed" And I should see a "img" within "#profile_photo_upload" diff --git a/features/step_definitions/custom_web_steps.rb b/features/step_definitions/custom_web_steps.rb index 041afe439..c5caf120c 100644 --- a/features/step_definitions/custom_web_steps.rb +++ b/features/step_definitions/custom_web_steps.rb @@ -182,6 +182,12 @@ Then /^(?:|I )should see a "([^\"]*)"(?: within "([^\"]*)")?$/ do |selector, sco end end +Then /^I should see (\d+) "([^\"]*)"(?: within "([^\"]*)")?$/ do |count, selector, scope_selector| + with_scope(scope_selector) do + expect(current_scope).to have_selector(selector, count: count) + end +end + Then /^(?:|I )should not see a "([^\"]*)"(?: within "([^\"]*)")?$/ do |selector, scope_selector| with_scope(scope_selector) do current_scope.should have_no_css(selector, :visible => true) diff --git a/features/step_definitions/web_steps.rb b/features/step_definitions/web_steps.rb index 38f7d0ab4..3cb2a75fb 100644 --- a/features/step_definitions/web_steps.rb +++ b/features/step_definitions/web_steps.rb @@ -96,7 +96,8 @@ When /^(?:|I )attach the file "([^"]*)" to (?:hidden )?"([^"]*)"(?: within "([^" attach_file(field, Rails.root.join(path).to_s) end # wait for the image to be ready - page.assert_selector(".loading", count: 0) + page.assert_no_selector(".loading") + page.assert_no_selector("#file-upload-spinner") end Then /^(?:|I )should see (\".+?\"[\s]*)(?:[\s]+within[\s]* "([^"]*)")?$/ do |vars, selector|