parent
097bd9ae67
commit
de2735f797
@ -0,0 +1,68 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- 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. |
||||
#++ |
||||
|
||||
module Queries::Filters::Shared::MeValueFilter |
||||
## |
||||
# Return the values object with the me value |
||||
# mapped to the current user. |
||||
def values_replaced |
||||
vals = values.clone |
||||
|
||||
if vals.delete(me_value_key) |
||||
if User.current.logged? |
||||
vals.push(User.current.id.to_s) |
||||
else |
||||
vals.push('0') |
||||
end |
||||
end |
||||
|
||||
vals |
||||
end |
||||
|
||||
protected |
||||
|
||||
## |
||||
# Returns the me value if the user is logged |
||||
def me_allowed_value |
||||
values = [] |
||||
if User.current.logged? |
||||
values << [me_label, me_value_key] |
||||
end |
||||
values |
||||
end |
||||
|
||||
def me_label |
||||
I18n.t(:label_me) |
||||
end |
||||
|
||||
def me_value_key |
||||
::Queries::Filters::MeValue::KEY |
||||
end |
||||
end |
@ -0,0 +1,94 @@ |
||||
<h3 class="widget-box--header" cdkDragHandle> |
||||
<i class="icon-context icon-time" aria-hidden="true"></i> |
||||
<span class="widget-box--header-title" [textContent]="text.title"></span> |
||||
</h3> |
||||
|
||||
<div class="total-hours"> |
||||
<p>Total: <span [textContent]="total"></span></p> |
||||
</div> |
||||
|
||||
<div class="generic-table--results-container" *ngIf="anyEntries"> |
||||
<table class="generic-table time-entries"> |
||||
<colgroup> |
||||
<col highlight-col> |
||||
<col highlight-col> |
||||
<col highlight-col> |
||||
<col highlight-col> |
||||
<col> |
||||
</colgroup> |
||||
<thead class="-sticky"> |
||||
<tr> |
||||
<th> |
||||
<div class="generic-table--sort-header-outer"> |
||||
<div class="generic-table--sort-header"> |
||||
<span [textContent]="text.activity"></span> |
||||
</div> |
||||
</div> |
||||
</th> |
||||
<th> |
||||
<div class="generic-table--sort-header-outer"> |
||||
<div class="generic-table--sort-header"> |
||||
<span [textContent]="text.workPackage"></span> |
||||
</div> |
||||
</div> |
||||
</th> |
||||
<th> |
||||
<div class="generic-table--sort-header-outer"> |
||||
<div class="generic-table--sort-header"> |
||||
<span [textContent]="text.comment"></span> |
||||
</div> |
||||
</div> |
||||
</th> |
||||
<th> |
||||
<div class="generic-table--sort-header-outer"> |
||||
<div class="generic-table--sort-header"> |
||||
<span [textContent]="text.hour"></span> |
||||
</div> |
||||
</div> |
||||
</th> |
||||
<th><div class="generic-table--empty-header"></div></th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
<tr class="time-entry" *ngFor="let item of rows"> |
||||
<td class="activity" |
||||
*ngIf="item.entry" |
||||
[textContent]="activityName(item.entry)"> |
||||
</td> |
||||
<td colspan="3" |
||||
*ngIf="item.sum"> |
||||
<strong [textContent]="item.date"></strong> |
||||
</td> |
||||
<td class="subject" |
||||
*ngIf="item.entry"> |
||||
{{projectName(item.entry)}} - <a [href]="workPackagePath(item.entry)" [textContent]="workPackageName(item.entry)"></a> |
||||
</td> |
||||
<td class="comments" |
||||
*ngIf="item.entry" |
||||
[textContent]="comment(item.entry)"> |
||||
</td> |
||||
<td class="hours" |
||||
*ngIf="item.entry" |
||||
[textContent]="hours(item.entry)"> |
||||
</td> |
||||
<td class="hours" |
||||
*ngIf="item.sum"> |
||||
<em [textContent]="item.sum | number : '1.2-2'"></em> |
||||
</td> |
||||
<td class="buttons"> |
||||
<a [href]="editPath(item.entry)" |
||||
*ngIf="item.entry && item.entry.updateImmediately" |
||||
[title]="text.edit"> |
||||
<op-icon icon-classes="icon-context icon-edit"></op-icon> |
||||
</a> |
||||
<a [href]="deletePath(item.entry)" |
||||
*ngIf="item.entry && item.entry.delete" |
||||
(click)="deleteIfConfirmed($event, item.entry)" |
||||
[title]="text.delete" > |
||||
<op-icon icon-classes="icon-context icon-delete"></op-icon> |
||||
</a> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
@ -0,0 +1,155 @@ |
||||
import {Component, OnInit} from "@angular/core"; |
||||
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component"; |
||||
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; |
||||
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service"; |
||||
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource"; |
||||
import {TimezoneService} from "core-components/datetime/timezone.service"; |
||||
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; |
||||
import {ConfirmDialogService} from "core-components/modals/confirm-dialog/confirm-dialog.service"; |
||||
import {formatNumber} from "@angular/common"; |
||||
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder"; |
||||
|
||||
@Component({ |
||||
templateUrl: './time-entries-current-user.component.html', |
||||
}) |
||||
|
||||
export class WidgetTimeEntriesCurrentUserComponent extends AbstractWidgetComponent implements OnInit { |
||||
public text = { |
||||
title: this.i18n.t('js.grid.widgets.title.time_entries_current_user'), |
||||
activity: this.i18n.t('js.time_entry.activity'), |
||||
comment: this.i18n.t('js.time_entry.comment'), |
||||
hour: this.i18n.t('js.time_entry.hours'), |
||||
workPackage: this.i18n.t('js.label_work_package'), |
||||
edit: this.i18n.t('js.button_edit'), |
||||
delete: this.i18n.t('js.button_delete'), |
||||
confirmDelete: { |
||||
text: this.i18n.t('js.text_are_you_sure'), |
||||
title: this.i18n.t('js.modals.form_submit.title') |
||||
} |
||||
}; |
||||
public entries:TimeEntryResource[] = []; |
||||
public rows:{ date:string, sum?:string, entry?:TimeEntryResource}[] = []; |
||||
|
||||
constructor(readonly timeEntryDm:TimeEntryDmService, |
||||
readonly timezone:TimezoneService, |
||||
readonly i18n:I18nService, |
||||
readonly pathHelper:PathHelperService, |
||||
readonly confirmDialog:ConfirmDialogService) { |
||||
super(i18n); |
||||
} |
||||
|
||||
ngOnInit() { |
||||
let filters = [['spentOn', '>t-', ['7']] as [string, FilterOperator, [string]], |
||||
['user_id', '=', ['me']] as [string, FilterOperator, [string]]]; |
||||
|
||||
this.timeEntryDm.list(filters) |
||||
.then((collection) => { |
||||
this.buildEntries(collection.elements); |
||||
}); |
||||
} |
||||
|
||||
public get total() { |
||||
let duration = this.entries.reduce((current, entry) => { |
||||
return current + this.timezone.toHours(entry.hours); |
||||
}, 0); |
||||
|
||||
return this.i18n.t('js.units.hour', { count: this.formatNumber(duration) }); |
||||
} |
||||
|
||||
public get anyEntries() { |
||||
return !!this.entries.length; |
||||
} |
||||
|
||||
public activityName(entry:TimeEntryResource) { |
||||
return entry.activity.name; |
||||
} |
||||
|
||||
public projectName(entry:TimeEntryResource) { |
||||
return entry.project.name; |
||||
} |
||||
|
||||
public workPackageName(entry:TimeEntryResource) { |
||||
return `#${entry.workPackage.idFromLink}: ${entry.workPackage.name}`; |
||||
} |
||||
|
||||
public workPackageId(entry:TimeEntryResource) { |
||||
return entry.workPackage.idFromLink; |
||||
} |
||||
|
||||
public comment(entry:TimeEntryResource) { |
||||
return entry.comment; |
||||
} |
||||
|
||||
public hours(entry:TimeEntryResource) { |
||||
return this.formatNumber(this.timezone.toHours(entry.hours)); |
||||
} |
||||
|
||||
public editPath(entry:TimeEntryResource) { |
||||
return this.pathHelper.timeEntryEditPath(entry.id); |
||||
} |
||||
|
||||
public deletePath(entry:TimeEntryResource) { |
||||
return this.pathHelper.timeEntryPath(entry.id); |
||||
} |
||||
|
||||
public workPackagePath(entry:TimeEntryResource) { |
||||
return this.pathHelper.workPackagePath(entry.workPackage.idFromLink); |
||||
} |
||||
|
||||
public deleteIfConfirmed(event:Event, entry:TimeEntryResource) { |
||||
event.preventDefault(); |
||||
|
||||
this.confirmDialog.confirm({ |
||||
text: this.text.confirmDelete, |
||||
closeByEscape: true, |
||||
showClose: true, |
||||
closeByDocument: true |
||||
}).then(() => { |
||||
entry.delete().then(() => { |
||||
let newEntries = this.entries.filter((anEntry) => { |
||||
return entry.id !== anEntry.id; |
||||
}); |
||||
|
||||
this.buildEntries(newEntries); |
||||
}); |
||||
}) |
||||
.catch(() => { |
||||
// nothing
|
||||
}); |
||||
} |
||||
|
||||
private buildEntries(entries:TimeEntryResource[]) { |
||||
this.entries = entries; |
||||
let sumsByDateSpent:{[key:string]:number} = {}; |
||||
|
||||
entries.forEach((entry) => { |
||||
let date = entry.spentOn; |
||||
|
||||
if (!sumsByDateSpent[date]) { |
||||
sumsByDateSpent[date] = 0; |
||||
} |
||||
|
||||
sumsByDateSpent[date] = sumsByDateSpent[date] + this.timezone.toHours(entry.hours); |
||||
}); |
||||
|
||||
let sortedEntries = entries.sort((a, b) => { |
||||
return b.spentOn.localeCompare(a.spentOn); |
||||
}); |
||||
|
||||
this.rows = []; |
||||
let currentDate:string|null = null; |
||||
sortedEntries.forEach((entry) => { |
||||
if (entry.spentOn !== currentDate) { |
||||
currentDate = entry.spentOn; |
||||
this.rows.push({date: this.timezone.formattedDate(currentDate!), sum: this.formatNumber(sumsByDateSpent[currentDate!])}); |
||||
} |
||||
|
||||
this.rows.push({date: currentDate!, entry: entry}); |
||||
}); |
||||
//entries
|
||||
} |
||||
|
||||
private formatNumber(value:number) { |
||||
return formatNumber(value, this.i18n.locale, '1.2-2'); |
||||
} |
||||
} |
@ -0,0 +1,51 @@ |
||||
//-- 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.
|
||||
//++
|
||||
|
||||
import {Injectable} from '@angular/core'; |
||||
import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.service'; |
||||
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service'; |
||||
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource"; |
||||
import {ApiV3FilterBuilder, FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder"; |
||||
|
||||
@Injectable() |
||||
export class TimeEntryDmService { |
||||
constructor(protected halResourceService:HalResourceService, |
||||
protected pathHelper:PathHelperService) { |
||||
} |
||||
|
||||
public list(filterParams:[string, FilterOperator, [string]][]):Promise<CollectionResource> { |
||||
let filters = new ApiV3FilterBuilder(); |
||||
filterParams.forEach((filterParam) => { |
||||
filters.add(...filterParam); |
||||
}); |
||||
|
||||
let params = `?${filters.toParams()}`; |
||||
|
||||
return this.halResourceService.get<CollectionResource>(this.pathHelper.api.v3.time_entries.toString() + params).toPromise(); |
||||
} |
||||
} |
@ -0,0 +1,32 @@ |
||||
//-- 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.
|
||||
//++
|
||||
|
||||
import {HalResource} from 'core-app/modules/hal/resources/hal-resource'; |
||||
|
||||
export class TimeEntryResource extends HalResource { |
||||
} |
@ -0,0 +1,126 @@ |
||||
#-- 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' |
||||
|
||||
describe 'My page time entries current user widget spec', type: :feature, js: true do |
||||
let!(:type) { FactoryBot.create :type } |
||||
let!(:project) { FactoryBot.create :project, types: [type] } |
||||
let!(:work_package) do |
||||
FactoryBot.create :work_package, |
||||
project: project, |
||||
type: type, |
||||
author: user |
||||
end |
||||
let!(:visible_time_entry) do |
||||
FactoryBot.create :time_entry, |
||||
work_package: work_package, |
||||
project: project, |
||||
user: user, |
||||
spent_on: Date.today, |
||||
hours: 6, |
||||
comments: 'My comment' |
||||
end |
||||
let!(:other_visible_time_entry) do |
||||
FactoryBot.create :time_entry, |
||||
work_package: work_package, |
||||
project: project, |
||||
user: user, |
||||
spent_on: Date.today - 1.day, |
||||
hours: 5, |
||||
comments: 'My other comment' |
||||
end |
||||
let!(:invisible_time_entry) do |
||||
FactoryBot.create :time_entry, |
||||
work_package: work_package, |
||||
project: project, |
||||
user: other_user, |
||||
hours: 4 |
||||
end |
||||
let(:other_user) do |
||||
FactoryBot.create(:user) |
||||
end |
||||
let(:user) do |
||||
FactoryBot.create(:user, |
||||
member_in_project: project, |
||||
member_with_permissions: %i[view_time_entries]) |
||||
end |
||||
let(:my_page) do |
||||
Pages::My::Page.new |
||||
end |
||||
|
||||
before do |
||||
login_as user |
||||
|
||||
my_page.visit! |
||||
end |
||||
|
||||
it 'adds the widget and checks the displayed entries' do |
||||
assigned_area = Components::Grids::GridArea.new('.grid--area', text: 'Work packages assigned to me') |
||||
|
||||
assigned_area.remove |
||||
|
||||
sleep(0.5) |
||||
|
||||
# within top-right area, add an additional widget |
||||
my_page.add_widget(1, 1, 'Spent time (last 7 days)') |
||||
|
||||
calendar_area = Components::Grids::GridArea.new('.grid--area', text: 'Spent time (last 7 days)') |
||||
calendar_area.expect_to_span(1, 1, 2, 2) |
||||
|
||||
calendar_area.resize_to(7, 2) |
||||
|
||||
# Resizing leads to the calendar area now spanning a larger area |
||||
calendar_area.expect_to_span(1, 1, 8, 3) |
||||
|
||||
expect(page) |
||||
.to have_content "Total: 11.00" |
||||
|
||||
expect(page) |
||||
.to have_content Date.today.strftime('%m/%d/%Y') |
||||
expect(page) |
||||
.to have_selector('.activity', text: visible_time_entry.activity.name) |
||||
expect(page) |
||||
.to have_selector('.subject', text: "#{project.name} - ##{work_package.id}: #{work_package.subject}") |
||||
expect(page) |
||||
.to have_selector('.comments', text: visible_time_entry.comments) |
||||
expect(page) |
||||
.to have_selector('.hours', text: visible_time_entry.hours) |
||||
|
||||
expect(page) |
||||
.to have_content (Date.today - 1.day).strftime('%m/%d/%Y') |
||||
expect(page) |
||||
.to have_selector('.activity', text: other_visible_time_entry.activity.name) |
||||
expect(page) |
||||
.to have_selector('.subject', text: "#{project.name} - ##{work_package.id}: #{work_package.subject}") |
||||
expect(page) |
||||
.to have_selector('.comments', text: other_visible_time_entry.comments) |
||||
expect(page) |
||||
.to have_selector('.hours', text: other_visible_time_entry.hours) |
||||
end |
||||
end |
Loading…
Reference in new issue