diff --git a/app/controllers/api/experimental/work_packages_controller.rb b/app/controllers/api/experimental/work_packages_controller.rb index 1f324cfc01..f34b4a6475 100644 --- a/app/controllers/api/experimental/work_packages_controller.rb +++ b/app/controllers/api/experimental/work_packages_controller.rb @@ -115,7 +115,9 @@ module Api def all_query_columns(query) columns = query.columns.map(&:name) + [:id] + columns << query.group_by.to_sym if query.group_by + columns += query.sort_criteria.map { |x| x.first.to_sym } columns end diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 8f2b3bfd37..e03d09b816 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -221,11 +221,12 @@ class WorkPackagesController < ApplicationController layout: 'angular' # !request.xhr? end format.csv do - serialized_work_packages = WorkPackage::Exporter.csv(@work_packages, @project) + serialized_work_packages = WorkPackage::Exporter.csv(@work_packages, @query) charset = "charset=#{l(:general_csv_encoding).downcase}" + title = @query.new_record? ? l(:label_work_package_plural) : @query.name send_data(serialized_work_packages, type: "text/csv; #{charset}; header=present", - filename: 'export.csv') + filename: "#{title}.csv") end format.pdf do serialized_work_packages = WorkPackage::Exporter.pdf(@work_packages, diff --git a/app/models/work_package/csv_exporter.rb b/app/models/work_package/csv_exporter.rb index f19edba4f9..5bfdfd36dc 100644 --- a/app/models/work_package/csv_exporter.rb +++ b/app/models/work_package/csv_exporter.rb @@ -30,60 +30,17 @@ module WorkPackage::CsvExporter include Redmine::I18n include CustomFieldsHelper + include ActionView::Helpers::TextHelper + include ActionView::Helpers::NumberHelper - def csv(work_packages, project = nil) - decimal_separator = l(:general_csv_decimal_separator) - + def csv(work_packages, query) export = CSV.generate(col_sep: l(:general_csv_separator)) do |csv| - # csv header fields - headers = ['#', - WorkPackage.human_attribute_name(:status), - WorkPackage.human_attribute_name(:project), - WorkPackage.human_attribute_name(:type), - WorkPackage.human_attribute_name(:priority), - WorkPackage.human_attribute_name(:subject), - WorkPackage.human_attribute_name(:assigned_to), - WorkPackage.human_attribute_name(:category), - WorkPackage.human_attribute_name(:fixed_version), - WorkPackage.human_attribute_name(:author), - WorkPackage.human_attribute_name(:start_date), - WorkPackage.human_attribute_name(:due_date), - WorkPackage.human_attribute_name(:done_ratio), - WorkPackage.human_attribute_name(:estimated_hours), - WorkPackage.human_attribute_name(:parent_work_package), - WorkPackage.human_attribute_name(:created_at), - WorkPackage.human_attribute_name(:updated_at) - ] - # Export project custom fields if project is given - # otherwise export custom fields marked as "For all projects" - custom_fields = project.nil? ? WorkPackageCustomField.for_all : project.all_work_package_custom_fields - custom_fields.each { |f| headers << f.name } - # Description in the last column - headers << CustomField.human_attribute_name(:description) + headers = csv_headers(query) csv << encode_csv_columns(headers) - # csv lines + work_packages.each do |work_package| - fields = [work_package.id, - work_package.status.name, - work_package.project.name, - work_package.type.name, - work_package.priority.name, - work_package.subject, - work_package.assigned_to, - work_package.category, - work_package.fixed_version, - work_package.author.name, - format_date(work_package.start_date), - format_date(work_package.due_date), - (Setting.work_package_done_ratio != 'disabled' ? work_package.done_ratio : ''), - work_package.estimated_hours.to_s.gsub('.', decimal_separator), - work_package.parent_id, - format_time(work_package.created_at), - format_time(work_package.updated_at) - ] - custom_fields.each { |f| fields << show_value(work_package.custom_value_for(f)) } - fields << work_package.description - csv << encode_csv_columns(fields) + row = csv_row(work_package, query) + csv << encode_csv_columns(row) end end @@ -95,4 +52,52 @@ module WorkPackage::CsvExporter Redmine::CodesetUtil.from_utf8(cell.to_s, encoding) end end + + private + + # fetch all headers + def csv_headers(query) + headers = [] + headers << '#' + + query.columns.each_with_index do |column, _| + headers << column.caption + end + + headers << CustomField.human_attribute_name(:description) + + headers + end + + # fetch all row values + def csv_row(work_package, query) + row = query.columns.collect do |column| + csv_format_value(work_package, column) + end + + if row.size > 0 + row.unshift(work_package.id.to_s) + row << work_package.description.gsub(/\r/, '').gsub(/\n/, ' ') + end + + row + end + + def csv_format_value(work_package, column) + if column.is_a?(QueryCustomFieldColumn) + cv = work_package.custom_values.detect { |v| v.custom_field_id == column.custom_field.id } + show_value(cv) + else + value = work_package.send(column.name) + + case value + when Date + format_date(value) + when Time + format_time(value) + else + value + end + end.to_s + end end diff --git a/frontend/tests/integration/protractor.conf.js b/frontend/tests/integration/protractor.conf.js index c0cb7b5e37..c6f538adfb 100644 --- a/frontend/tests/integration/protractor.conf.js +++ b/frontend/tests/integration/protractor.conf.js @@ -39,10 +39,10 @@ exports.config = { specs: ['work-packages-spec.js', 'work-package-details-spec.js'], - allScriptsTimeout: 40000, + allScriptsTimeout: 500000, mochaOpts: { - timeout: 40000, + timeout: 500000, reporter: 'mocha-jenkins-reporter' }, diff --git a/frontend/tests/integration/work-package-details-spec.js b/frontend/tests/integration/work-package-details-spec.js index de84c6a19e..c3111808ef 100644 --- a/frontend/tests/integration/work-package-details-spec.js +++ b/frontend/tests/integration/work-package-details-spec.js @@ -41,9 +41,10 @@ describe('OpenProject', function() { function loadPane(workPackageId) { var page = new WorkPackageDetailsPane(workPackageId, 'overview'); page.get(); + browser.waitForAngular(); } - context('pane itself', function() { + describe('pane itself', function() { beforeEach(function() { loadPane(819); }); @@ -245,8 +246,7 @@ describe('OpenProject', function() { describe('activities', function() { describe('overview tab', function() { before(function() { - var page = new WorkPackageDetailsPane(819, 'overview'); - page.get(); + loadPane(819); }); it('should render the last 3 activites', function() { diff --git a/lib/redmine/access_control.rb b/lib/redmine/access_control.rb index 0cfee39e88..3092a83557 100644 --- a/lib/redmine/access_control.rb +++ b/lib/redmine/access_control.rb @@ -79,15 +79,9 @@ module Redmine end class Mapper - def initialize - @project_module = nil - @project_modules_without_permissions = [] - end - def permission(name, hash, options = {}) - @permissions ||= [] options.merge!(project_module: @project_module) - @permissions << Permission.new(name, hash, options) + mapped_permissions << Permission.new(name, hash, options) end def project_module(name, _options = {}) @@ -96,16 +90,16 @@ module Redmine yield self @project_module = nil else - @project_modules_without_permissions << name + project_modules_without_permissions << name end end def mapped_permissions - @permissions + @permissions ||= [] end def project_modules_without_permissions - @project_modules_without_permissions + @project_modules_without_permissions ||= [] end end diff --git a/spec/controllers/work_packages_controller_spec.rb b/spec/controllers/work_packages_controller_spec.rb index e91a83833c..279e6c7e29 100644 --- a/spec/controllers/work_packages_controller_spec.rb +++ b/spec/controllers/work_packages_controller_spec.rb @@ -193,12 +193,13 @@ describe WorkPackagesController, type: :controller do before do mock_csv = double('csv export') - expect(WorkPackage::Exporter).to receive(:csv).with(work_packages, project) - .and_return(mock_csv) + expect(WorkPackage::Exporter).to receive(:csv).with(work_packages, query) + .and_return(mock_csv) expect(controller).to receive(:send_data).with(mock_csv, type: 'text/csv; charset=utf-8; header=present', - filename: 'export.csv') do |*_args| + filename: "#{query.name}.csv") do |_| + # We need to render something because otherwise # the controller will and he will not find a suitable template controller.render text: 'success' diff --git a/spec/features/support/select_2.rb b/spec/features/support/select_2.rb new file mode 100644 index 0000000000..25f772a393 --- /dev/null +++ b/spec/features/support/select_2.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 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. +#++ + +shared_context 'select2 helpers' do + def select2_select_option(select2_element, option_name) + select2_element.find('.select2-choice').click + + within('.select2-drop') do + find('.select2-result-selectable', text: option_name).click + end + end +end diff --git a/spec/features/support/work_package_table.rb b/spec/features/support/work_package_table.rb new file mode 100644 index 0000000000..4507e5289a --- /dev/null +++ b/spec/features/support/work_package_table.rb @@ -0,0 +1,87 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 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. +#++ + +shared_context 'work package table helpers' do + def remove_wp_table_column(column_name) + click_button('Settings') + click_link('Columns ...') + + # This is faster than has_selector but does not wait for anything. + # So if problems occur, switch to has_selector? + if find('.select2-choices').text.include?(column_name) + find('.select2-search-choice', text: column_name) + .click_link('select2-search-choice-close') + end + + click_button('Apply') + end + + def sort_wp_table_by(column_name, order: :desc) + click_button('Settings') + click_link('Sort by ...') + + # If the modal was accessible, this would be elegant. + first_sort_criteria = all('.select2-container')[0] + select2_select_option(first_sort_criteria, column_name) + + # If the modal was accessible, this would be elegant. + order_name = order == :desc ? 'Descending' : 'Ascending' + + first_sort_order = all('.select2-container')[1] + select2_select_option(first_sort_order, order_name) + + click_button('Apply') + end + + def expect_work_packages_to_be_in_order(order) + within_wp_table do + preceeding_elements = order[0..-2] + following_elements = order[1..-1] + + preceeding_elements.each_with_index do |wp_1, i| + wp_2 = following_elements[i] + expect(self).to have_selector("#work-package-#{wp_1.id} + \ + #work-package-#{wp_2.id}") + end + end + end + + def within_wp_table(&block) + within('.work-packages-table--results-container', &block) + end + + def ensure_wp_page_is_loaded + # This is here to ensure the page is loaded completely before the next spec + # is run. As the filters are loaded late in the page, all Ajax requests + # have been answered by then. Without this, requests still running from + # the last spec, might expect data that has already been removed as + # preparation for the current spec. + find('#work-packages-filter-toggle-button').click + expect(page).to have_selector('.filter label', text: 'Status') + end +end diff --git a/spec/features/work_packages/select_work_package_row_spec.rb b/spec/features/work_packages/select_work_package_row_spec.rb index bfe9179ee0..f7a4ee0e31 100644 --- a/spec/features/work_packages/select_work_package_row_spec.rb +++ b/spec/features/work_packages/select_work_package_row_spec.rb @@ -46,6 +46,8 @@ describe 'Select work package row', type: :feature do } let(:work_packages_page) { WorkPackagesPage.new(project) } + include_context 'work package table helpers' + before do allow(User).to receive(:current).and_return(user) @@ -57,13 +59,7 @@ describe 'Select work package row', type: :feature do end after do - # This is here to ensure the page is loaded completely before the next spec - # is run. As the filters are loaded late in the page, all Ajax requests - # have been answered by then. Without this, requests still running from - # the last spec, might expect data that has already been removed as - # preparation for the current spec. - find('#work-packages-filter-toggle-button').click - expect(page).to have_selector('.filter label', text: 'Status') + ensure_wp_page_is_loaded end describe 'Work package row selection', js: true do diff --git a/spec/features/work_packages/table_sorting_spec.rb b/spec/features/work_packages/table_sorting_spec.rb new file mode 100644 index 0000000000..d9ba7a18f7 --- /dev/null +++ b/spec/features/work_packages/table_sorting_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' +require 'features/work_packages/work_packages_page' + +describe 'Select work package row', type: :feature do + let(:user) { FactoryGirl.create(:admin) } + let(:project) { FactoryGirl.create(:project) } + let(:work_package_1) do + FactoryGirl.create(:work_package, project: project) + end + let(:work_package_2) do + FactoryGirl.create(:work_package, project: project) + end + let(:work_packages_page) { WorkPackagesPage.new(project) } + + let(:version_1) do + FactoryGirl.create(:version, project: project, + name: 'aaa_version') + end + let(:version_2) do + FactoryGirl.create(:version, project: project, + name: 'zzz_version') + end + + before do + allow(User).to receive(:current).and_return(user) + + work_package_1 + work_package_2 + + work_packages_page.visit_index + end + + include_context 'select2 helpers' + include_context 'work package table helpers' + + after do + ensure_wp_page_is_loaded + end + + context 'sorting by version', js: true do + before do + work_package_1.update_attribute(:fixed_version_id, version_2.id) + work_package_2.update_attribute(:fixed_version_id, version_1.id) + end + + it 'sorts by version although version is not selected as a column' do + remove_wp_table_column('Version') + + sort_wp_table_by('Version') + + expect_work_packages_to_be_in_order([work_package_1, work_package_2]) + end + end +end