Merge pull request #3550 from oliverguenther/feature/user_preference

Feature/user preference
pull/3639/head
Markus Kahl 9 years ago
commit 19c317e866
  1. 2
      app/helpers/application_helper.rb
  2. 4
      app/models/user.rb
  3. 66
      app/models/user_preference.rb
  4. 2
      features/work_packages/attachments.feature
  5. 2
      features/work_packages/preview.feature
  6. 1
      lib/api/v3/root.rb
  7. 13
      lib/api/v3/root_representer.rb
  8. 65
      lib/api/v3/user_preferences/user_preferences_api.rb
  9. 76
      lib/api/v3/user_preferences/user_preferences_representer.rb
  10. 4
      lib/api/v3/utilities/path_helper.rb
  11. 3
      lib/api/v3/work_packages/work_packages_shared_helpers.rb
  12. 2
      spec/features/work_packages/details/activity_comments_spec.rb
  13. 26
      spec/lib/api/v3/root_representer_spec.rb
  14. 103
      spec/lib/api/v3/user_preferences/user_preferences_representer_spec.rb
  15. 6
      spec/lib/api/v3/utilities/path_helper_spec.rb
  16. 4
      spec/lib/api/v3/work_packages/work_packages_shared_helpers_spec.rb
  17. 102
      spec/models/user_preference_spec.rb
  18. 11
      spec/requests/api/v3/root_resource_spec.rb
  19. 141
      spec/requests/api/v3/user_preferences/user_preferences_resource_spec.rb

@ -559,7 +559,7 @@ module ApplicationHelper
I18n.defaultLocale = "#{I18n.default_locale}";
I18n.locale = "#{I18n.locale}";
})
unless User.current.pref.warn_on_leaving_unsaved == '0'
if User.current.pref.warn_on_leaving_unsaved?
tags += javascript_tag(%{
jQuery(document).ready(function(){
warnLeavingUnsaved('#{escape_javascript(l(:text_warn_on_leaving_unsaved))}');

@ -442,7 +442,7 @@ class User < Principal
end
def impaired
(anonymous? && Setting.accessibility_mode_for_anonymous?) || !!pref.impaired
(anonymous? && Setting.accessibility_mode_for_anonymous?) || pref.impaired?
end
def impaired?
@ -450,7 +450,7 @@ class User < Principal
end
def wants_comments_in_reverse_order?
pref[:comments_sorting] == 'desc'
pref.comments_in_reverse_order?
end
# Return user's RSS key (a 40 chars long string), used to access feeds

@ -32,6 +32,8 @@ class UserPreference < ActiveRecord::Base
serialize :others
validates_presence_of :user
validate :time_zone_correctness, if: -> { time_zone.present? }
validate :theme_correctness, if: -> { theme.present? }
attr_accessible :user
@ -59,25 +61,81 @@ class UserPreference < ActiveRecord::Base
others[:comments_sorting] = order
end
def comments_in_reverse_order?
comments_sorting == 'desc'
end
def warn_on_leaving_unsaved?
# Need to cast here as previous values were '0' / '1'
to_boolean(others.fetch(:warn_on_leaving_unsaved) { true })
end
def warn_on_leaving_unsaved=(value)
others[:warn_on_leaving_unsaved] = to_boolean(value)
end
# Provide an alias to form builders
alias :comments_in_reverse_order :comments_in_reverse_order?
alias :warn_on_leaving_unsaved :warn_on_leaving_unsaved?
def comments_in_reverse_order=(value)
others[:comments_sorting] = to_boolean(value) ? 'desc' : 'asc'
end
def theme
others[:theme] || OpenProject::Themes.application_theme_identifier
end
def theme=(order)
others[:theme] = order
def theme=(identifier)
others[:theme] = identifier.nil? ? nil : identifier.to_sym
end
def canonical_time_zone
return if time_zone.nil?
zone = ActiveSupport::TimeZone.new(time_zone)
unless zone.nil?
zone.tzinfo.canonical_identifier
end
end
def impaired?
!!impaired
end
def warn_on_leaving_unsaved?
# Need to cast here as previous values were '0' / '1'
to_boolean(others.fetch(:warn_on_leaving_unsaved) { true })
end
def warn_on_leaving_unsaved
others.fetch(:warn_on_leaving_unsaved) { '1' }
warn_on_leaving_unsaved?
end
def warn_on_leaving_unsaved=(value)
others[:warn_on_leaving_unsaved] = value
others[:warn_on_leaving_unsaved] = to_boolean(value)
end
private
def to_boolean(value)
ActiveRecord::Type::Boolean.new.type_cast_from_user(value)
end
def init_other_preferences
self.others ||= { no_self_notified: true }
end
def time_zone_correctness
errors.add(:time_zone, :inclusion) if time_zone.present? && canonical_time_zone.nil?
end
def theme_correctness
return true if theme == OpenProject::Themes.application_theme_identifier
themes = OpenProject::Themes.all.map(&:identifier)
unless themes.any? { |identifier| theme.to_sym == identifier }
errors.add(:theme, :inclusion)
end
end
end

@ -44,7 +44,7 @@ Feature: Attachments on work packages
And there is 1 user with the following:
| login | bob|
And the user "bob" has the following preferences
| warn_on_leaving_unsaved | 0 |
| warn_on_leaving_unsaved | false |
And the user "bob" is a "member" in the project "parent"
And there are the following issue status:
| name | is_closed | is_default |

@ -45,7 +45,7 @@ Feature: Switching types of work packages
| lastname | Bobbit |
# prevent alerts to occur that would impede subsequent scenarios
And the user "bob" has the following preferences
| warn_on_leaving_unsaved | 0 |
| warn_on_leaving_unsaved | false |
And the user "bob" is a "member" in the project "project1"
And I am already logged in as "bob"

@ -47,6 +47,7 @@ module API
mount ::API::V3::StringObjects::StringObjectsAPI
mount ::API::V3::Types::TypesAPI
mount ::API::V3::Users::UsersAPI
mount ::API::V3::UserPreferences::UserPreferencesAPI
mount ::API::V3::Versions::VersionsAPI
mount ::API::V3::WorkPackages::WorkPackagesAPI

@ -38,6 +38,19 @@ module API
}
end
link :user do
{
href: api_v3_paths.user(current_user.id),
title: current_user.name
} if current_user.logged?
end
link :userPreferences do
{
href: api_v3_paths.my_preferences
} if current_user.logged?
end
link :priorities do
{
href: api_v3_paths.priorities

@ -0,0 +1,65 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require_dependency 'api/v3/user_preferences/user_preferences_representer'
module API
module V3
module UserPreferences
class UserPreferencesAPI < ::API::OpenProjectAPI
resource :my_preferences do
helpers do
def represent_preferences
UserPreferencesRepresenter.new(@preferences, current_user: current_user)
end
end
before do
fail ::API::Errors::Unauthenticated unless current_user.logged?
@preferences = current_user.pref
end
get do
represent_preferences
end
patch do
representer = represent_preferences
representer.from_hash(request_body)
if @preferences.save
representer
else
raise ::API::Errors::ErrorBase.create_and_merge_errors(@preferences.errors)
end
end
end
end
end
end
end

@ -0,0 +1,76 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'roar/decorator'
require 'roar/json/hal'
module API
module V3
module UserPreferences
class UserPreferencesRepresenter < ::API::Decorators::Single
link :self do
{
href: api_v3_paths.my_preferences
}
end
link :user do
{
href: api_v3_paths.user(represented.user.id),
title: represented.user.name
}
end
link :updateImmediately do
{
href: api_v3_paths.my_preferences,
method: :patch
}
end
property :hide_mail
property :time_zone,
getter: -> (*) { canonical_time_zone },
render_nil: true
property :theme
property :warn_on_leaving_unsaved
property :comments_in_reverse_order,
as: :commentSortDescending
property :impaired?,
as: :accessibilityMode
def _type
'UserPreferences'
end
end
end
end
end

@ -84,6 +84,10 @@ module API
"#{work_packages_by_project(project_id)}/form"
end
def self.my_preferences
"#{root}/my_preferences"
end
def self.priorities
"#{root}/priorities"
end

@ -34,9 +34,6 @@ module API
module WorkPackages
module WorkPackagesSharedHelpers
extend Grape::API::Helpers
def request_body
env['api.request.body']
end
def merge_hash_into_work_package!(hash, work_package)
payload = ::API::V3::WorkPackages::WorkPackagePayloadRepresenter.create(work_package)

@ -20,7 +20,7 @@ describe 'activity comments', js: true do
before do
login_as(user)
allow(user.pref).to receive(:warn_on_leaving_unsaved).and_return('0')
allow(user.pref).to receive(:warn_on_leaving_unsaved?).and_return(false)
end
context 'with permission' do

@ -31,7 +31,8 @@ require 'spec_helper'
describe ::API::V3::RootRepresenter do
include ::API::V3::Utilities::PathHelper
let(:representer) { described_class.new({}, current_user: double('current_user')) }
let(:user) { FactoryGirl.build(:user) }
let(:representer) { described_class.new({}, current_user: user) }
let(:app_title) { 'Foo Project' }
let(:version) { 'The version is over 9000!' }
@ -68,6 +69,29 @@ describe ::API::V3::RootRepresenter do
let(:link) { 'workPackages' }
let(:href) { api_v3_paths.work_packages }
end
it_behaves_like 'has a titled link' do
let(:link) { 'user' }
let(:href) { api_v3_paths.user(user.id) }
let(:title) { user.name }
end
it_behaves_like 'has an untitled link' do
let(:link) { 'userPreferences' }
let(:href) { api_v3_paths.my_preferences }
end
context 'anonymous user' do
let(:representer) { described_class.new({}, current_user: User.anonymous) }
it_behaves_like 'has no link' do
let(:link) { 'user' }
end
it_behaves_like 'has no link' do
let(:link) { 'userPreferences' }
end
end
end
it 'shows the name of the instance' do

@ -0,0 +1,103 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::UserPreferences::UserPreferencesRepresenter do
include ::API::V3::Utilities::PathHelper
let(:preference) { FactoryGirl.build(:user_preference) }
let(:user) { FactoryGirl.build_stubbed(:user, preference: preference) }
let(:representer) { described_class.new(preference, current_user: user) }
before do
allow(preference).to receive(:user).and_return(user)
end
context 'generation' do
subject(:generated) { representer.to_json }
it { is_expected.to include_json('UserPreferences'.to_json).at_path('_type') }
it { is_expected.to have_json_path('hideMail') }
it { is_expected.to have_json_path('timeZone') }
it { is_expected.to have_json_path('theme') }
it { is_expected.to have_json_path('commentSortDescending') }
it { is_expected.to have_json_path('warnOnLeavingUnsaved') }
it { is_expected.to have_json_path('accessibilityMode') }
describe 'timeZone' do
context 'no time zone set' do
let(:preference) { FactoryGirl.build(:user_preference, time_zone: '') }
it 'shows the timeZone as nil' do
is_expected.to be_json_eql(nil.to_json).at_path('timeZone')
end
end
context 'short timezone set' do
let(:preference) { FactoryGirl.build(:user_preference, time_zone: 'Berlin') }
it 'shows the canonical time zone' do
is_expected.to be_json_eql('Europe/Berlin'.to_json).at_path('timeZone')
end
end
context 'canonical timezone set' do
let(:preference) { FactoryGirl.build(:user_preference, time_zone: 'Europe/Paris') }
it 'shows the canonical time zone' do
is_expected.to be_json_eql('Europe/Paris'.to_json).at_path('timeZone')
end
end
end
describe '_links' do
it_behaves_like 'has an untitled link' do
let(:link) { 'self' }
let(:href) { api_v3_paths.my_preferences }
end
it_behaves_like 'has a titled link' do
let(:link) { 'user' }
let(:title) { user.name }
let(:href) { api_v3_paths.user(user.id) }
end
describe 'immediate update' do
it_behaves_like 'has an untitled link' do
let(:link) { 'updateImmediately' }
let(:href) { api_v3_paths.my_preferences }
end
it 'is a patch link' do
is_expected.to be_json_eql('patch'.to_json).at_path('_links/updateImmediately/method')
end
end
end
end
end

@ -120,6 +120,12 @@ describe ::API::V3::Utilities::PathHelper do
it_behaves_like 'api v3 path', '/projects/42/work_packages/form'
end
describe '#user_preferences' do
subject { helper.my_preferences }
it_behaves_like 'api v3 path', '/my_preferences'
end
describe '#render_markup' do
subject { helper.render_markup(format: 'super_fancy', link: 'link-ish') }

@ -49,6 +49,10 @@ describe ::API::V3::WorkPackages::WorkPackagesSharedHelpers do
@env
end
def request_body
@env['api.request.body']
end
def current_user
@user
end

@ -29,7 +29,8 @@
require 'spec_helper'
describe UserPreference do
subject { described_class.new }
let(:user) { FactoryGirl.build_stubbed(:user) }
subject { FactoryGirl.build(:user_preference, user: user) }
describe 'default settings' do
it 'hides the email address' do
@ -44,4 +45,103 @@ describe UserPreference do
expect(subject.others[:no_self_notified]).to be_truthy
end
end
shared_examples 'accepts real and false booleans' do |setter, getter|
it 'accepts true boolean' do
subject.send(setter, true)
expect(subject.send(getter)).to be true
subject.send(setter, false)
expect(subject.send(getter)).to be false
end
it 'accepts false booleans' do
%w(true 1).each do |str|
subject.send(setter, str)
expect(subject.send(getter)).to be true
end
%w(false 0).each do |str|
subject.send(setter, str)
expect(subject.send(getter)).to be false
end
end
end
describe 'sort order' do
it_behaves_like 'accepts real and false booleans',
:comments_in_reverse_order=,
:comments_in_reverse_order?
it 'can be changed by string' do
subject.comments_sorting = 'desc'
expect(subject.comments_in_reverse_order?).to be true
subject.comments_sorting = 'asc'
expect(subject.comments_in_reverse_order?).to be false
end
end
describe 'warn on unsaved changes' do
it_behaves_like 'accepts real and false booleans',
:warn_on_leaving_unsaved=,
:warn_on_leaving_unsaved?
end
describe 'time_zone' do
it 'allows to save short time zones' do
subject.time_zone = 'Berlin'
expect(subject).to be_valid
expect(subject.time_zone).to eq('Berlin')
expect(subject.canonical_time_zone).to eq('Europe/Berlin')
end
it 'allows to set full time zones' do
subject.time_zone = 'Europe/Paris'
expect(subject).to be_valid
expect(subject.time_zone).to eq('Europe/Paris')
expect(subject.canonical_time_zone).to eq('Europe/Paris')
end
it 'disallows invalid time zones' do
subject.time_zone = 'Berlin123'
expect(subject).not_to be_valid
end
it 'allows empty values' do
subject.time_zone = nil
expect(subject).to be_valid
subject.time_zone = ''
expect(subject).to be_valid
end
end
describe 'theme' do
it 'allows to save valid themes' do
subject.theme = 'default'
expect(subject).to be_valid
expect(subject.theme).to eq(:default)
subject.theme = :default
expect(subject).to be_valid
expect(subject.theme).to eq(:default)
end
it 'allows empty values' do
subject.theme = nil
expect(subject).to be_valid
subject.theme = ''
expect(subject).to be_valid
end
it 'rejects invalid themes' do
subject.theme = :mycoolthemethatisnotavailableyet
expect(subject).not_to be_valid
subject.theme = 'mycoolthemethatisnotavailableyet'
expect(subject).not_to be_valid
end
end
end

@ -40,7 +40,8 @@ describe 'API v3 Root resource' do
let(:project) { FactoryGirl.create(:project, is_public: false) }
describe '#get' do
subject(:response) { last_response }
let(:response) { last_response }
subject { response.body }
let(:get_path) { api_v3_paths.root }
context 'anonymous user' do
@ -49,11 +50,11 @@ describe 'API v3 Root resource' do
end
it 'should respond with 200' do
expect(subject.status).to eq(200)
expect(response.status).to eq(200)
end
it 'should respond with a root representer' do
expect(subject.body).to have_json_path('instanceName')
expect(subject).to have_json_path('instanceName')
end
end
@ -65,11 +66,11 @@ describe 'API v3 Root resource' do
end
it 'should respond with 200' do
expect(subject.status).to eq(200)
expect(response.status).to eq(200)
end
it 'should respond with a root representer' do
expect(subject.body).to have_json_path('instanceName')
expect(subject).to have_json_path('instanceName')
end
end
end

@ -0,0 +1,141 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe 'API v3 UserPreferences resource', type: :request do
include Rack::Test::Methods
include ::API::V3::Utilities::PathHelper
let(:user) { FactoryGirl.create(:user) }
let(:preference) { FactoryGirl.create(:user_preference, user: user) }
let(:representer) { described_class.new(preference, current_user: user) }
let(:preference_path) { api_v3_paths.my_preferences }
subject(:response) { last_response }
before do
allow(User).to receive(:current).and_return user
allow(User).to receive(:preference).and_return preference
end
describe '#GET' do
before do
get preference_path
end
context 'when not logged in' do
let(:user) { User.anonymous }
it 'should respond with 401' do
expect(subject.status).to eq(401)
end
end
context 'when logged in' do
it 'should respond with 200' do
expect(subject.status).to eq(200)
end
it 'should respond with a UserPreferences representer' do
expect(subject.body).to be_json_eql('UserPreferences'.to_json).at_path('_type')
end
end
end
describe '#PATCH' do
before do
patch preference_path, params.to_json, 'CONTENT_TYPE' => 'application/json'
preference.reload
end
context 'when not logged in' do
let(:user) { User.anonymous }
let(:params) do
{ whatever: true }
end
it 'should respond with 401' do
expect(subject.status).to eq(401)
end
end
describe 'theme' do
context 'with invalid identifier' do
let(:params) do
{ theme: 'mycoolthemethatisnotavailableyet' }
end
it_behaves_like 'constraint violation' do
let(:message) { 'Theme is not set to one of the allowed values.' }
end
end
context 'with correct identifier' do
let(:params) do
{ theme: 'default' }
end
it 'should respond with a UserPreferences representer' do
expect(subject.body).to be_json_eql(:default.to_json).at_path('theme')
expect(preference.theme).to eq(:default)
end
end
end
describe 'timezone' do
context 'with invalid timezone' do
let(:params) do
{ timeZone: 'Europe/Awesomeland' }
end
it_behaves_like 'constraint violation' do
let(:message) { 'Time zone is not set to one of the allowed values.' }
end
end
context 'with full time zone' do
let(:params) do
{ timeZone: 'Europe/Paris' }
end
it 'should respond with a UserPreferences representer' do
expect(subject.body).to be_json_eql('Europe/Paris'.to_json).at_path('timeZone')
expect(preference.time_zone).to eq('Europe/Paris')
end
end
context 'with short time zone' do
let(:params) do
{ timeZone: 'Hawaii' }
end
it 'should respond with a UserPreferences representer' do
expect(subject.body).to be_json_eql('Pacific/Honolulu'.to_json).at_path('timeZone')
expect(preference.time_zone).to eq('Hawaii')
expect(preference.canonical_time_zone).to eq('Pacific/Honolulu')
end
end
end
end
end
Loading…
Cancel
Save