Merge pull request #8345 from opf/feature/32126-use-timelogging-widget-on-cost-reports-page

[32126] Use time logging widget on cost reports page

[ci skip]
pull/8366/head
Oliver Günther 5 years ago committed by GitHub
commit c86e77897b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      frontend/src/app/global-dynamic-components.const.ts
  2. 4
      frontend/src/app/modules/grids/widgets/time-entries/list/time-entries-list.component.ts
  3. 6
      frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts
  4. 1
      frontend/src/app/modules/time_entries/edit/edit.service.ts
  5. 87
      frontend/src/app/modules/time_entries/edit/trigger-actions-entry.component.ts
  6. 4
      frontend/src/app/modules/time_entries/openproject-time-entries.module.ts
  7. 6
      modules/reporting/app/helpers/reporting_helper.rb
  8. 29
      modules/reporting/lib/widget/table/entry_table.rb
  9. 98
      modules/reporting/spec/features/support/components/cost_reports_base_table.rb
  10. 8
      modules/reporting/spec/features/support/pages/cost_report_page.rb
  11. 135
      modules/reporting/spec/features/update_entries_spec.rb
  12. 6
      modules/reporting_engine/lib/assets/javascripts/reporting_engine/reporting/controls.js

@ -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 },
];

@ -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);
})

@ -75,6 +75,12 @@ export class TimeEntryDmService extends AbstractDmService<TimeEntryResource> {
.toPromise();
}
public delete(resource:TimeEntryResource) {
return this.halResourceService
.delete<TimeEntryResource>(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);

@ -13,7 +13,6 @@ export class TimeEntryEditService {
constructor(readonly opModalService:OpModalService,
readonly injector:Injector,
readonly halResource:HalResourceService,
protected halEditing:HalResourceEditingService,
readonly i18n:I18nService) {
}

@ -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: `
<a *ngIf="entry"
(click)="editTimeEntry(entry)"
[title]="text.edit"
class="no-decoration-on-hover">
<op-icon icon-classes="icon-context icon-edit"></op-icon>
</a>
<a *ngIf="entry"
(click)="deleteTimeEntry(entry)"
[title]="text.delete"
class="no-decoration-on-hover">
<op-icon icon-classes="icon-context icon-delete"></op-icon>
</a>
`,
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);
});
}
}

@ -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 {

@ -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
##

@ -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

@ -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

@ -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

@ -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

@ -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) {

Loading…
Cancel
Save