diff --git a/frontend/src/app/global-dynamic-components.const.ts b/frontend/src/app/global-dynamic-components.const.ts index 20ad149a10..ebdfee7d03 100644 --- a/frontend/src/app/global-dynamic-components.const.ts +++ b/frontend/src/app/global-dynamic-components.const.ts @@ -130,6 +130,10 @@ import { EEActiveSavedTrialComponent, enterpriseActiveSavedTrialSelector } from "core-components/enterprise/enterprise-active-trial/ee-active-saved-trial.component"; +import { + TriggerActionsEntryComponent, + triggerActionsEntryComponentSelector +} from "core-app/modules/time_entries/edit/trigger-actions-entry.component"; export const globalDynamicComponents:OptionalBootstrapDefinition[] = [ { selector: appBaseSelector, cls: ApplicationBaseComponent }, @@ -170,6 +174,7 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [ { selector: remoteFieldUpdaterSelector, cls: RemoteFieldUpdaterComponent }, { selector: wpOverviewGraphSelector, cls: WorkPackageOverviewGraphComponent }, { selector: wpQuerySelectSelector, cls: WorkPackageQuerySelectDropdownComponent }, + { selector: triggerActionsEntryComponentSelector, cls: TriggerActionsEntryComponent, embeddable: true }, ]; diff --git a/frontend/src/app/modules/grids/widgets/time-entries/list/time-entries-list.component.ts b/frontend/src/app/modules/grids/widgets/time-entries/list/time-entries-list.component.ts index 5c95ec0f7f..6f36b25764 100644 --- a/frontend/src/app/modules/grids/widgets/time-entries/list/time-entries-list.component.ts +++ b/frontend/src/app/modules/grids/widgets/time-entries/list/time-entries-list.component.ts @@ -101,7 +101,9 @@ export abstract class WidgetTimeEntriesListComponent extends AbstractWidgetCompo this.timeEntryEditService .edit(loadedEntry) .then((changedEntry) => { - let newEntries = Object.assign(this.entries, [changedEntry.entry]); + let oldEntryIndex:number = this.entries.findIndex(el => el.id === changedEntry.entry.id); + let newEntries = this.entries; + newEntries[oldEntryIndex] = changedEntry.entry; this.buildEntries(newEntries); }) diff --git a/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts b/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts index 28aa36969f..4bba2b7b3f 100644 --- a/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts @@ -75,6 +75,12 @@ export class TimeEntryDmService extends AbstractDmService { .toPromise(); } + public delete(resource:TimeEntryResource) { + return this.halResourceService + .delete(this.pathHelper.api.v3.time_entries.id(resource.idFromLink).toString()) + .toPromise(); + } + public extractPayload(resource:TimeEntryResource|null = null, schema:SchemaResource|null = null) { if (resource && schema) { return this.payloadDm.extract(resource, schema); diff --git a/frontend/src/app/modules/time_entries/edit/edit.service.ts b/frontend/src/app/modules/time_entries/edit/edit.service.ts index eaff602155..343cda4bdb 100644 --- a/frontend/src/app/modules/time_entries/edit/edit.service.ts +++ b/frontend/src/app/modules/time_entries/edit/edit.service.ts @@ -13,7 +13,6 @@ export class TimeEntryEditService { constructor(readonly opModalService:OpModalService, readonly injector:Injector, readonly halResource:HalResourceService, - protected halEditing:HalResourceEditingService, readonly i18n:I18nService) { } diff --git a/frontend/src/app/modules/time_entries/edit/trigger-actions-entry.component.ts b/frontend/src/app/modules/time_entries/edit/trigger-actions-entry.component.ts new file mode 100644 index 0000000000..f9400217a6 --- /dev/null +++ b/frontend/src/app/modules/time_entries/edit/trigger-actions-entry.component.ts @@ -0,0 +1,87 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Injector, OnInit} from "@angular/core"; +import {InjectField} from "core-app/helpers/angular/inject-field.decorator"; +import {TimeEntryEditService} from "core-app/modules/time_entries/edit/edit.service"; +import {TimeEntryCacheService} from "core-components/time-entries/time-entry-cache.service"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource"; +import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service"; +import {NotificationsService} from "core-app/modules/common/notifications/notifications.service"; + +export const triggerActionsEntryComponentSelector = 'time-entry--trigger-actions-entry'; + +@Component({ + selector: triggerActionsEntryComponentSelector, + template: ` + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TriggerActionsEntryComponent implements OnInit { + @InjectField() readonly timeEntryEditService:TimeEntryEditService; + @InjectField() readonly timeEntryCache:TimeEntryCacheService; + @InjectField() readonly timeEntryDmService:TimeEntryDmService; + @InjectField() readonly notificationsService:NotificationsService; + @InjectField() readonly elementRef:ElementRef; + @InjectField() readonly i18n:I18nService; + @InjectField() readonly cdRef:ChangeDetectorRef; + + public entry:TimeEntryResource; + + public text = { + edit: this.i18n.t('js.button_edit'), + delete: this.i18n.t('js.button_delete'), + error: this.i18n.t('js.error.internal'), + areYouSure: this.i18n.t('js.text_are_you_sure') + }; + + constructor(readonly injector:Injector) { + + } + + ngOnInit() { + let timeEntryId = this.elementRef.nativeElement.dataset['entry']; + this.timeEntryCache + .require(timeEntryId) + .then((loadedEntry) => { + this.entry = loadedEntry; + this.cdRef.detectChanges(); + }); + } + + editTimeEntry(entry:TimeEntryResource) { + this.timeEntryEditService + .edit(entry) + .then(() => { + window.location.reload(); + }) + .catch(() => { + // User canceled the modal + }); + } + + deleteTimeEntry(entry:TimeEntryResource) { + if (!window.confirm(this.text.areYouSure)) { + return; + } + + this.timeEntryDmService + .delete(entry) + .then(() => { + window.location.reload(); + }) + .catch((error) => { + this.notificationsService.addError(error || this.text.error); + }); + } +} diff --git a/frontend/src/app/modules/time_entries/openproject-time-entries.module.ts b/frontend/src/app/modules/time_entries/openproject-time-entries.module.ts index 1a8f14d90c..06c9eff466 100644 --- a/frontend/src/app/modules/time_entries/openproject-time-entries.module.ts +++ b/frontend/src/app/modules/time_entries/openproject-time-entries.module.ts @@ -33,6 +33,7 @@ import {TimeEntryCreateModal} from "core-app/modules/time_entries/create/create. import {TimeEntryEditModal} from "core-app/modules/time_entries/edit/edit.modal"; import {TimeEntryFormComponent} from "core-app/modules/time_entries/form/form.component"; import {TimeEntryEditService} from "core-app/modules/time_entries/edit/edit.service"; +import {TriggerActionsEntryComponent} from "core-app/modules/time_entries/edit/trigger-actions-entry.component"; @NgModule({ imports: [ @@ -48,7 +49,8 @@ import {TimeEntryEditService} from "core-app/modules/time_entries/edit/edit.serv declarations: [ TimeEntryEditModal, TimeEntryCreateModal, - TimeEntryFormComponent + TimeEntryFormComponent, + TriggerActionsEntryComponent ] }) export class OpenprojectTimeEntriesModule { diff --git a/modules/reporting/app/helpers/reporting_helper.rb b/modules/reporting/app/helpers/reporting_helper.rb index 359c9fa7e3..fa9a2ef4cf 100644 --- a/modules/reporting/app/helpers/reporting_helper.rb +++ b/modules/reporting/app/helpers/reporting_helper.rb @@ -185,7 +185,11 @@ module ReportingHelper ## # Create the appropriate action for an entry with the type of log to use def action_for(result, options = {}) - options.merge controller: result.fields['type'] == 'TimeEntry' ? 'timelog' : 'costlog', id: result.fields['id'].to_i + options.merge controller: controller_for(result.fields['type']), id: result.fields['id'].to_i + end + + def controller_for(type) + type == 'TimeEntry' ? 'timelog' : 'costlog' end ## diff --git a/modules/reporting/lib/widget/table/entry_table.rb b/modules/reporting/lib/widget/table/entry_table.rb index f511bf3dc7..9b2b779a0e 100644 --- a/modules/reporting/lib/widget/table/entry_table.rb +++ b/modules/reporting/lib/widget/table/entry_table.rb @@ -155,17 +155,24 @@ class ::Widget::Table::EntryTable < ::Widget::Table icons = '' with_project(result.fields['project_id']) do if entry_for(result).editable_by? User.current - icons = link_to(icon_wrapper('icon-context icon-edit', l(:button_edit)), - action_for(result, action: 'edit'), - class: 'no-decoration-on-hover', - title: l(:button_edit)) - icons << link_to(icon_wrapper('icon-context icon-delete', l(:button_delete)), - (action_for(result, action: 'destroy') - .reverse_merge(authenticity_token: form_authenticity_token)), - data: { confirm: l(:text_are_you_sure) }, - method: :delete, - class: 'no-decoration-on-hover', - title: l(:button_delete)) + if controller_for(result.fields['type']) == 'costlog' + icons = link_to(icon_wrapper('icon-context icon-edit', l(:button_edit)), + action_for(result, action: 'edit'), + class: 'no-decoration-on-hover', + title: l(:button_edit)) + + icons << link_to(icon_wrapper('icon-context icon-delete', l(:button_delete)), + (action_for(result, action: 'destroy') + .reverse_merge(authenticity_token: form_authenticity_token)), + data: { confirm: l(:text_are_you_sure) }, + method: :delete, + class: 'no-decoration-on-hover', + title: l(:button_delete)) + else + icons = content_tag(:'time-entry--trigger-actions-entry', + '', + data: { entry: result['id'] }) + end end end icons diff --git a/modules/reporting/spec/features/support/components/cost_reports_base_table.rb b/modules/reporting/spec/features/support/components/cost_reports_base_table.rb new file mode 100644 index 0000000000..76fd508be6 --- /dev/null +++ b/modules/reporting/spec/features/support/components/cost_reports_base_table.rb @@ -0,0 +1,98 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2020 the OpenProject GmbH +# +# 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. +#++ + + +module Components + class CostReportsBaseTable + include Capybara::DSL + include RSpec::Matchers + + attr_reader :time_logging_modal + + def initialize + @time_logging_modal = Components::TimeLoggingModal.new + end + + def rows_count(count) + expect(page).to have_selector('#result-table tbody tr', count: count) + end + + def expect_action_icon(icon, row, present: true) + if present + expect(page).to have_selector("#{row_selector(row)} .icon-#{icon}") + else + expect(page).to have_no_selector("#{row_selector(row)} .icon-#{icon}") + end + end + + def expect_value(value, row) + expect(page).to have_selector("#{row_selector(row)} .units", text: value) + end + + def edit_time_entry(new_value, row) + page.find("#{row_selector(row)} .icon-edit").click + + time_logging_modal.is_visible true + time_logging_modal.update_field 'hours', new_value + time_logging_modal.work_package_is_missing false + + time_logging_modal.perform_action 'Save' + + sleep(3) + + expect_action_icon 'edit', row + expect_value new_value, row + end + + def edit_cost_entry(new_value, row, cost_entry_id) + page.find("#{row_selector(row)} .icon-edit").click + + expect(page).to have_current_path('/cost_entries/' + cost_entry_id + '/edit') + + fill_in('cost_entry_units', with: new_value) + click_button 'Save' + expect(page).to have_selector('.flash.notice') + + sleep(3) + end + + def delete_entry(row) + page.find("#{row_selector(row)} .icon-delete").click + + page.driver.browser.switch_to.alert.accept + + sleep(3) + end + + private + + def row_selector(row) + "#result-table tbody tr:nth-of-type(#{row})" + end + end +end diff --git a/modules/reporting/spec/features/support/pages/cost_report_page.rb b/modules/reporting/spec/features/support/pages/cost_report_page.rb index f2d62e9eaa..0497f0cb85 100644 --- a/modules/reporting/spec/features/support/pages/cost_report_page.rb +++ b/modules/reporting/spec/features/support/pages/cost_report_page.rb @@ -79,6 +79,14 @@ module Pages end end + def show_loading_indicator(present: true) + if present + expect(page).to have_selector('#ajax-indicator') + else + expect(page).to have_no_selector('#ajax-indicator') + end + end + def path cost_reports_path(project) end diff --git a/modules/reporting/spec/features/update_entries_spec.rb b/modules/reporting/spec/features/update_entries_spec.rb new file mode 100644 index 0000000000..ce4a18ac98 --- /dev/null +++ b/modules/reporting/spec/features/update_entries_spec.rb @@ -0,0 +1,135 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2020 the OpenProject GmbH +# +# 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' +require_relative 'support/pages/cost_report_page' +require_relative 'support/components/cost_reports_base_table' + +describe 'Updating entries within the cost report', type: :feature, js: true do + let(:project) { FactoryBot.create :project } + let(:user) { FactoryBot.create :admin } + let(:work_package) { FactoryBot.create :work_package, project: project } + + let!(:time_entry_user) do + FactoryBot.create :time_entry, + user: user, + work_package: work_package, + project: project, + hours: 5 + end + + let(:cost_type) do + type = FactoryBot.create :cost_type, name: 'My cool type' + FactoryBot.create :cost_rate, cost_type: type, rate: 7.00 + type + end + + let!(:cost_entry_user) do + FactoryBot.create :cost_entry, + work_package: work_package, + project: project, + units: 3.00, + cost_type: cost_type, + user: user + end + + let(:report_page) { ::Pages::CostReportPage.new project } + let(:table) { ::Components::CostReportsBaseTable.new } + + before do + login_as(user) + visit cost_reports_path(project) + report_page.clear + report_page.apply + report_page.show_loading_indicator present: false + end + + it 'can edit and delete time entries' do + table.rows_count 1 + + table.expect_action_icon 'edit', 1 + table.expect_action_icon 'delete', 1 + + table.edit_time_entry 2, 1 + + table.delete_entry 1 + table.rows_count 0 + end + + it 'can edit and delete cost entries' do + table.rows_count 1 + + report_page.switch_to_type 'My cool type' + report_page.show_loading_indicator present: false + + table.rows_count 1 + + table.expect_action_icon 'edit', 1 + table.expect_action_icon 'delete', 1 + + table.edit_cost_entry 2, 1, cost_entry_user.id.to_s + visit cost_reports_path(project) + table.rows_count 1 + + table.delete_entry 1 + table.rows_count 0 + end + + it 'shows the action icons after a table refresh' do + table.rows_count 1 + + table.expect_action_icon 'edit', 1 + table.expect_action_icon 'delete', 1 + + # Force a reload of the table (although nothing has changed) + report_page.apply + sleep(1) + report_page.show_loading_indicator present: false + + table.rows_count 1 + + table.expect_action_icon 'edit', 1 + table.expect_action_icon 'delete', 1 + end + + context 'as user without permissions' do + let(:role) { FactoryBot.create :role, permissions: %i(view_time_entries) } + let!(:user) do + FactoryBot.create :user, + member_in_project: project, + member_through_role: role + end + + it 'cannot edit or delete' do + table.rows_count 1 + + table.expect_action_icon 'edit', 1, present: false + table.expect_action_icon 'delete', 1, present: false + end + end +end diff --git a/modules/reporting_engine/lib/assets/javascripts/reporting_engine/reporting/controls.js b/modules/reporting_engine/lib/assets/javascripts/reporting_engine/reporting/controls.js index 38aba3c020..44cd901384 100644 --- a/modules/reporting_engine/lib/assets/javascripts/reporting_engine/reporting/controls.js +++ b/modules/reporting_engine/lib/assets/javascripts/reporting_engine/reporting/controls.js @@ -112,6 +112,12 @@ Reporting.Controls = function($){ var update_result_table = function (response) { $('#result-table').html(response); + + window.OpenProject.pluginContext + .valuesPromise() + .then((context) => { + context.bootstrap(document.getElementById('result-table')); + }); }; var default_failure_callback = function (response) {