diff --git a/app/assets/stylesheets/content/work_packages/inplace_editing/_display_fields.sass b/app/assets/stylesheets/content/work_packages/inplace_editing/_display_fields.sass index 6b8e100b06..ac5a4e4531 100644 --- a/app/assets/stylesheets/content/work_packages/inplace_editing/_display_fields.sass +++ b/app/assets/stylesheets/content/work_packages/inplace_editing/_display_fields.sass @@ -36,13 +36,24 @@ &.-placeholder font-style: italic - span.-derived - @include varprop(color, gray-dark) - font-style: italic - font-weight: bold + &.split-time-field + white-space: nowrap + .-actual-value, + .-derived-value + display: inline-block + width: 48% + + .-actual-value + text-align: right + padding-right: 0.5rem + + .-derived-value + @include varprop(color, gray-dark) + font-style: italic + font-weight: bold - span.-with-actual - margin-left: 0.5em + &:not(.-with-actual-value) + margin-left: 48% .wp-edit-field--text, wp-edit-field diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 89968c4e4a..7f37badf90 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -139,6 +139,14 @@ class CustomField < ActiveRecord::Base end end + def value_of(value) + if list? + custom_options.where(value: value).pluck(:id).first + else + CustomValue.new(custom_field: self, value: value).valid? && value + end + end + ## # Returns possible values for this custom field. # Options may be a customizable, or options suitable for ActiveRecord#read_attribute. diff --git a/app/models/mail_handler.rb b/app/models/mail_handler.rb index 2ddbe7ac04..65bb76cdbb 100644 --- a/app/models/mail_handler.rb +++ b/app/models/mail_handler.rb @@ -346,7 +346,7 @@ class MailHandler < ActionMailer::Base def custom_field_values_from_keywords(customized) "#{customized.class.name}CustomField".constantize.all.inject({}) do |h, v| if value = get_keyword(v.name, override: true) - h[v.id.to_s] = value + h[v.id.to_s] = v.value_of value end h end diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index c50e65d57d..838529b48f 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -52,6 +52,7 @@ en: button_back_to_list_view: "Back to list view" button_cancel: "Cancel" button_close: "Close" + button_change_project: "Change project" button_check_all: "Check all" button_configure-form: "Configure form" button_confirm: "Confirm" @@ -69,7 +70,6 @@ en: button_show_view: "Fullscreen view" button_log_time: "Log time" button_more: "More" - button_move: "Move" button_open_details: "Open details view" button_close_details: "Close details view" button_open_fullscreen: "Open fullscreen view" @@ -646,7 +646,7 @@ en: image: "Image" work_packages: bulk_actions: - move: 'Bulk move' + move: 'Bulk change of project' edit: 'Bulk edit' copy: 'Bulk copy' delete: 'Bulk delete' diff --git a/db/migrate/20190722082648_add_derived_estimated_hours_to_work_packages.rb b/db/migrate/20190722082648_add_derived_estimated_hours_to_work_packages.rb index 679014f90a..23132b186d 100644 --- a/db/migrate/20190722082648_add_derived_estimated_hours_to_work_packages.rb +++ b/db/migrate/20190722082648_add_derived_estimated_hours_to_work_packages.rb @@ -22,6 +22,12 @@ class AddDerivedEstimatedHoursToWorkPackages < ActiveRecord::Migration[5.2] migrate_to_derived_estimated_hours! end end + + change.down do + WorkPackage.transaction do + rollback_from_derived_estimated_hours! + end + end end end @@ -34,7 +40,6 @@ class AddDerivedEstimatedHoursToWorkPackages < ActiveRecord::Migration[5.2] # only touches the derived_estimated_hours column. def migrate_to_derived_estimated_hours! last_id = Journal.order(id: :desc).limit(1).pluck(:id).first || 0 - wp_journals = "work_package_journals" work_packages = WorkPackageWithRelations.with_children.where("estimated_hours > ?", 0) work_packages.update_all("derived_estimated_hours = estimated_hours, estimated_hours = NULL") @@ -45,7 +50,22 @@ class AddDerivedEstimatedHoursToWorkPackages < ActiveRecord::Migration[5.2] create_customizable_journals last_id: last_id create_attachable_journals last_id: last_id - work_packages.each(&:touch) # invalidate cache + touch_work_packages work_packages # to invalidate cache + end + + def rollback_from_derived_estimated_hours! + last_id = Journal.order(id: :desc).limit(1).pluck(:id).first || 0 + + work_packages = WorkPackageWithRelations.with_children.where("derived_estimated_hours > ?", 0) + work_packages.update_all("estimated_hours = derived_estimated_hours, derived_estimated_hours = NULL") + work_packages = WorkPackageWithRelations.with_children.where("estimated_hours > ?", 0) + + create_journals_for work_packages, notes: rollback_notes + create_work_package_journals last_id: last_id + create_customizable_journals last_id: last_id + create_attachable_journals last_id: last_id + + touch_work_packages work_packages # to invalidate cache end ## @@ -76,6 +96,10 @@ class AddDerivedEstimatedHoursToWorkPackages < ActiveRecord::Migration[5.2] "_'Estimated hours' changed to 'Derived estimated hours'_" end + def rollback_notes + "_'Derived estimated hours' rolled back to 'Estimated hours'_" + end + ## # Creates work package journals for the move of estimated_hours to derived_estimated_hours. # @@ -164,4 +188,12 @@ class AddDerivedEstimatedHoursToWorkPackages < ActiveRecord::Migration[5.2] WHERE #{journals}.id > #{last_id} -- make sure to only create entries for the newly created journals ") end + + def touch_work_packages(work_packages) + where = work_packages.arel.where_sql + + WorkPackage.connection.execute(" + UPDATE work_packages SET updated_at = NOW(), lock_version = lock_version + 1 #{where} + ") + end end diff --git a/frontend/src/app/components/op-context-menu/wp-context-menu/wp-static-context-menu-actions.ts b/frontend/src/app/components/op-context-menu/wp-context-menu/wp-static-context-menu-actions.ts index 0f7d0c59c1..3f796d1a40 100644 --- a/frontend/src/app/components/op-context-menu/wp-context-menu/wp-static-context-menu-actions.ts +++ b/frontend/src/app/components/op-context-menu/wp-context-menu/wp-static-context-menu-actions.ts @@ -5,7 +5,8 @@ export const PERMITTED_CONTEXT_MENU_ACTIONS = [ resource: 'workPackage' }, { - key: 'move', + key: 'change_project', + icon: 'icon-move', link: 'move', resource: 'workPackage' }, diff --git a/frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass b/frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass index 78c5dcc0a7..a3d3d68780 100644 --- a/frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass +++ b/frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass @@ -13,6 +13,7 @@ box-shadow: 1px 1px 3px 0px lightgrey background: var(--body-background) font-size: var(--card-font-size) + max-width: 400px &:hover box-shadow: 0px 0px 10px lightgrey diff --git a/frontend/src/app/components/wp-card-view/wp-card-view.component.ts b/frontend/src/app/components/wp-card-view/wp-card-view.component.ts index 1b7fee67ab..fc469954dc 100644 --- a/frontend/src/app/components/wp-card-view/wp-card-view.component.ts +++ b/frontend/src/app/components/wp-card-view/wp-card-view.component.ts @@ -3,11 +3,14 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ElementRef, EventEmitter, + ElementRef, + EventEmitter, Inject, Injector, Input, - OnInit, Output, ViewChild + OnInit, + Output, + ViewChild } from "@angular/core"; import {QueryResource} from 'core-app/modules/hal/resources/query-resource'; import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space"; @@ -25,8 +28,6 @@ import {CardHighlightingMode} from "core-components/wp-fast-table/builders/highl import {AuthorisationService} from "core-app/modules/common/model-auth/model-auth.service"; import {StateService} from "@uirouter/core"; import {States} from "core-components/states.service"; -import {DragAndDropService} from "core-app/modules/common/drag-and-drop/drag-and-drop.service"; -import {DragAndDropHelpers} from "core-app/modules/common/drag-and-drop/drag-and-drop.helpers"; import {WorkPackageViewOrderService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-order.service"; import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; import {filter, withLatestFrom} from 'rxjs/operators'; @@ -35,7 +36,7 @@ import {WorkPackageViewSelectionService} from "core-app/modules/work_packages/ro import {CardViewHandlerRegistry} from "core-components/wp-card-view/event-handler/card-view-handler-registry"; import {WorkPackageCardViewService} from "core-components/wp-card-view/services/wp-card-view.service"; import {WorkPackageCardDragAndDropService} from "core-components/wp-card-view/services/wp-card-drag-and-drop.service"; -import {uiStateLinkClass, checkedClassName} from "core-components/wp-fast-table/builders/ui-state-link-builder"; +import {checkedClassName, uiStateLinkClass} from "core-components/wp-fast-table/builders/ui-state-link-builder"; export type CardViewOrientation = 'horizontal'|'vertical'; diff --git a/frontend/src/app/modules/fields/display/field-types/wp-display-duration-field.module.ts b/frontend/src/app/modules/fields/display/field-types/wp-display-duration-field.module.ts index 926b101304..0004f8ee39 100644 --- a/frontend/src/app/modules/fields/display/field-types/wp-display-duration-field.module.ts +++ b/frontend/src/app/modules/fields/display/field-types/wp-display-duration-field.module.ts @@ -65,6 +65,7 @@ export class DurationDisplayField extends DisplayField { return; } + element.classList.add('split-time-field'); let value = this.value; let actual:number = (value && this.timezoneService.toHours(value)) || 0; @@ -83,6 +84,7 @@ export class DurationDisplayField extends DisplayField { span.textContent = displayText; span.title = this.valueString; + span.classList.add('-actual-value') element.appendChild(span); } @@ -91,12 +93,12 @@ export class DurationDisplayField extends DisplayField { const span = document.createElement('span'); span.setAttribute('title', this.texts.empty); - span.textContent = "(" + (actualPresent ? "+" : "") + displayText + ")"; + span.textContent = '(' + (actualPresent ? '+' : '') + displayText + ')'; span.title = `${this.derivedValueString} ${this.derivedText}`; - span.classList.add("-derived"); + span.classList.add('-derived-value'); if (actualPresent) { - span.classList.add("-with-actual"); + span.classList.add('-with-actual-value'); } element.appendChild(span); diff --git a/frontend/src/app/modules/work_packages/routing/wp-view-base/view-services/wp-view-selection.service.ts b/frontend/src/app/modules/work_packages/routing/wp-view-base/view-services/wp-view-selection.service.ts index 3661426227..4ecec307be 100644 --- a/frontend/src/app/modules/work_packages/routing/wp-view-base/view-services/wp-view-selection.service.ts +++ b/frontend/src/app/modules/work_packages/routing/wp-view-base/view-services/wp-view-selection.service.ts @@ -1,7 +1,7 @@ import {input} from 'reactivestates'; import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space"; import {WorkPackageCacheService} from 'core-components/work-packages/work-package-cache.service'; -import {Injectable, Injector, OnDestroy} from '@angular/core'; +import {Injectable, OnDestroy} from '@angular/core'; import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource'; import {States} from 'core-components/states.service'; import {OPContextMenuService} from "core-components/op-context-menu/op-context-menu.service"; @@ -15,7 +15,6 @@ export interface WorkPackageViewSelectionState { activeRowIndex:number | null; } - @Injectable() export class WorkPackageViewSelectionService implements OnDestroy { diff --git a/spec/features/work_packages/bulk/move_work_package_spec.rb b/spec/features/work_packages/bulk/move_work_package_spec.rb index cb1876330c..a29149e882 100644 --- a/spec/features/work_packages/bulk/move_work_package_spec.rb +++ b/spec/features/work_packages/bulk/move_work_package_spec.rb @@ -74,7 +74,7 @@ describe 'Moving a work package through Rails view', js: true do expect(child_wp.project_id).to eq(project.id) context_menu.open_for work_package - context_menu.choose 'Move' + context_menu.choose 'Change project' # On work packages move page expect(page).to have_selector('#new_project_id') @@ -117,7 +117,7 @@ describe 'Moving a work package through Rails view', js: true do it 'does not allow to move' do context_menu.open_for work_package - context_menu.expect_no_options 'Move' + context_menu.expect_no_options 'Change project' end end end @@ -134,7 +134,7 @@ describe 'Moving a work package through Rails view', js: true do it 'does allow to move' do context_menu.open_for work_package, false - context_menu.expect_options ['Bulk move'] + context_menu.expect_options ['Bulk change of project'] end end @@ -143,7 +143,33 @@ describe 'Moving a work package through Rails view', js: true do it 'does not allow to move' do context_menu.open_for work_package, false - context_menu.expect_no_options ['Bulk move'] + context_menu.expect_no_options ['Bulk change of project'] + end + end + end + + describe 'accessing the bulk move from the card view' do + before do + display_representation.switch_to_card_layout + loading_indicator_saveguard + find('body').send_keys [:control, 'a'] + end + + context 'with permissions' do + let(:current_user) { mover } + + it 'does allow to move' do + context_menu.open_for work_package, false + context_menu.expect_options ['Bulk change of project'] + end + end + + context 'without permission' do + let(:current_user) { dev } + + it 'does not allow to move' do + context_menu.open_for work_package, false + context_menu.expect_no_options ['Bulk change of project'] end end end diff --git a/spec/features/work_packages/table/context_menu_spec.rb b/spec/features/work_packages/table/context_menu_spec.rb index fc0b9b2592..c44aa2e305 100644 --- a/spec/features/work_packages/table/context_menu_spec.rb +++ b/spec/features/work_packages/table/context_menu_spec.rb @@ -47,7 +47,7 @@ describe 'Work package table context menu', js: true do # Open Move goto_context_menu list_view - menu.choose('Move') + menu.choose('Change project') expect(page).to have_selector('h2', text: I18n.t(:button_move)) expect(page).to have_selector('a.issue', text: "##{work_package.id}") @@ -100,7 +100,7 @@ describe 'Work package table context menu', js: true do menu.open_for(work_package, list_view) menu.expect_options ['Open details view', 'Open fullscreen view', - 'Bulk edit', 'Bulk copy', 'Bulk move', 'Bulk delete'] + 'Bulk edit', 'Bulk copy', 'Bulk change of project', 'Bulk delete'] end end end diff --git a/spec/fixtures/mail_handler/work_package_with_list_custom_field.eml b/spec/fixtures/mail_handler/work_package_with_list_custom_field.eml new file mode 100644 index 0000000000..74a4044cc3 --- /dev/null +++ b/spec/fixtures/mail_handler/work_package_with_list_custom_field.eml @@ -0,0 +1,18 @@ +Return-Path: +Received: from osiris ([127.0.0.1]) + by OSIRIS + with hMailServer ; Sun, 22 Jun 2008 12:28:07 +0200 +Message-ID: <000501c8d452$a95cd7e0$0a00a8c0@osiris> +From: "Hans Wurst" +To: +Subject: OnlineStore - new Bug #42: Work package with list custom field +Date: Sun, 22 Jun 2008 12:28:07 +0200 +MIME-Version: 1.0 +Content-Type: text/plain; + format=flowed; + charset="iso-8859-1"; + reply-type=original +Content-Transfer-Encoding: 7bit + +This is a ticket submitted with a list custom field. +Letters:B diff --git a/spec/fixtures/mail_handler/work_package_with_text_custom_field.eml b/spec/fixtures/mail_handler/work_package_with_text_custom_field.eml new file mode 100644 index 0000000000..a188a435ee --- /dev/null +++ b/spec/fixtures/mail_handler/work_package_with_text_custom_field.eml @@ -0,0 +1,18 @@ +Return-Path: +Received: from osiris ([127.0.0.1]) + by OSIRIS + with hMailServer ; Sun, 22 Jun 2008 12:28:07 +0200 +Message-ID: <000501c8d452$a95cd7e0$0a00a8c0@osiris> +From: "Hans Wurst" +To: +Subject: OnlineStore - new Bug #42: Work package with text custom field +Date: Sun, 22 Jun 2008 12:28:07 +0200 +MIME-Version: 1.0 +Content-Type: text/plain; + format=flowed; + charset="iso-8859-1"; + reply-type=original +Content-Transfer-Encoding: 7bit + +This is a ticket submitted with a text custom field. +Notes:some text diff --git a/spec/models/mail_handler_spec.rb b/spec/models/mail_handler_spec.rb index 25693361fc..7c3dae7276 100644 --- a/spec/models/mail_handler_spec.rb +++ b/spec/models/mail_handler_spec.rb @@ -112,6 +112,54 @@ describe MailHandler, type: :model do expect(work_package.attachments.length).to eq 2 end end + + context 'with a custom field' do + let(:work_package) { FactoryBot.create :work_package, project: project } + let(:type) { FactoryBot.create :type } + + before do + type.custom_fields << custom_field + type.save! + + allow_any_instance_of(WorkPackage).to receive(:available_custom_fields).and_return([custom_field]) + + expect(WorkPackage).to receive(:find_by).with(id: 42).and_return(work_package) + expect(User).to receive(:find_by_mail).with("h.wurst@openproject.com").and_return(mail_user) + end + + context 'of type text' do + let(:custom_field) { FactoryBot.create :text_wp_custom_field, name: "Notes" } + + before do + submit_email 'work_package_with_text_custom_field.eml', issue: { project: project.identifier } + + work_package.reload + end + + it "sets the value" do + value = work_package.custom_values.where(custom_field_id: custom_field.id).pluck(:value).first + + expect(value).to eq "some text" # as given in .eml fixture + end + end + + context 'of type list' do + let(:custom_field) { FactoryBot.create :list_wp_custom_field, name: "Letters", possible_values: %w(A B C) } + + before do + submit_email 'work_package_with_list_custom_field.eml', issue: { project: project.identifier } + + work_package.reload + end + + it "sets the value" do + option = CustomOption.where(custom_field_id: custom_field.id, value: "B").first # as given in .eml fixture + value = work_package.custom_values.where(custom_field_id: custom_field.id).pluck(:value).first + + expect(value).to eq option.id.to_s + end + end + end end describe '#category' do diff --git a/spec/support/work_packages/work_package_cards.rb b/spec/support/work_packages/work_package_cards.rb new file mode 100644 index 0000000000..b2bb32b101 --- /dev/null +++ b/spec/support/work_packages/work_package_cards.rb @@ -0,0 +1,53 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 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-2017 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 docs/COPYRIGHT.rdoc for more details. +#++ +require 'spec_helper' + +class WorkPackageCards + include Capybara::DSL + include RSpec::Matchers + attr_reader :project + + def initialize(project = nil) + @project = project + end + + def open_full_screen_by_doubleclick(work_package) + loading_indicator_saveguard + page.driver.browser.action.double_click(card(work_package).native).perform + + Pages::FullWorkPackage.new(work_package, project) + end + + def select_work_package(work_package) + card(work_package).click + end + + def card(work_package) + page.find(".wp-card-#{work_package.id}") + end +end