implement time entries widget

pull/6834/head
Jens Ulferts 6 years ago
parent 097bd9ae67
commit de2735f797
No known key found for this signature in database
GPG Key ID: 3CAA4B1182CF5308
  1. 8
      app/assets/stylesheets/content/_grid.sass
  2. 1
      app/models/grids/configuration.rb
  3. 68
      app/models/queries/filters/shared/me_value_filter.rb
  4. 11
      app/models/queries/time_entries/filters/user_filter.rb
  5. 36
      app/models/queries/work_packages/filter/me_value_filter_mixin.rb
  6. 9
      config/locales/js-en.yml
  7. 22
      docs/api/apiv3/endpoints/time_entries.apib
  8. 2
      frontend/src/app/components/api/api-v3/api-v3-filter-builder.ts
  9. 3
      frontend/src/app/modules/common/path-helper/apiv3/apiv3-paths.ts
  10. 5
      frontend/src/app/modules/grids/grid.component.html
  11. 9
      frontend/src/app/modules/grids/openproject-grids.module.ts
  12. 12
      frontend/src/app/modules/grids/widgets/abstract-widget.component.ts
  13. 2
      frontend/src/app/modules/grids/widgets/add/add.modal.ts
  14. 94
      frontend/src/app/modules/grids/widgets/time-entries-current-user/time-entries-current-user.component.html
  15. 155
      frontend/src/app/modules/grids/widgets/time-entries-current-user/time-entries-current-user.component.ts
  16. 3
      frontend/src/app/modules/grids/widgets/wp-assigned/wp-assigned.component.ts
  17. 2
      frontend/src/app/modules/grids/widgets/wp-calendar/wp-calendar.component.html
  18. 2
      frontend/src/app/modules/grids/widgets/wp-calendar/wp-calendar.component.ts
  19. 2
      frontend/src/app/modules/grids/widgets/wp-created/wp-created.component.ts
  20. 2
      frontend/src/app/modules/grids/widgets/wp-watched/wp-watched.component.ts
  21. 12
      frontend/src/app/modules/grids/widgets/wp-widget/wp-widget.component.css
  22. 2
      frontend/src/app/modules/grids/widgets/wp-widget/wp-widget.component.html
  23. 51
      frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts
  24. 8
      frontend/src/app/modules/hal/openproject-hal.module.ts
  25. 32
      frontend/src/app/modules/hal/resources/time-entry-resource.ts
  26. 6
      frontend/src/app/modules/hal/services/hal-resource.config.ts
  27. 2
      lib/api/v3/time_entries/time_entries_api.rb
  28. 27
      lib/api/v3/time_entries/time_entry_representer.rb
  29. 126
      spec/features/my/my_page_time_entries_current_user_spec.rb
  30. 64
      spec/lib/api/v3/time_entries/time_entry_representer_rendering_spec.rb
  31. 41
      spec/models/queries/time_entries/time_entry_query_spec.rb
  32. 6
      spec/requests/api/v3/time_entry_resource_spec.rb
  33. 4
      spec/support/components/grids/grid_area.rb
  34. 7
      spec/support/pages/my/page.rb

@ -43,6 +43,14 @@ $grid--header-width: 20px
.cdk-drag-handle
cursor: grab
.grid--area-content
height: 100%
ng-component
display: flex
flex-direction: column
height: 100%
.grid--widget-add
padding: 15px
background-color: $gray

@ -106,3 +106,4 @@ Grids::Configuration.register_widget('work_packages_assigned', 'MyPageGrid')
Grids::Configuration.register_widget('work_packages_watched', 'MyPageGrid')
Grids::Configuration.register_widget('work_packages_created', 'MyPageGrid')
Grids::Configuration.register_widget('work_packages_calendar', 'MyPageGrid')
Grids::Configuration.register_widget('time_entries_current_user', 'MyPageGrid')

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

@ -29,13 +29,22 @@
#++
class Queries::TimeEntries::Filters::UserFilter < Queries::TimeEntries::Filters::TimeEntryFilter
include Queries::Filters::Shared::MeValueFilter
def allowed_values
@allowed_values ||= begin
# We don't care for the first value as we do not display the values visibly
::Principal.in_visible_project.pluck(:id).map { |id| [id, id.to_s] }
me_allowed_value + ::Principal
.in_visible_project
.pluck(:id)
.map { |id| [id, id.to_s] }
end
end
def where
operator_strategy.sql_for_field(values_replaced, self.class.model.table_name, self.class.key)
end
def type
:list_optional
end

@ -31,6 +31,7 @@
##
# Mixin to a filter or strategy
module Queries::WorkPackages::Filter::MeValueFilterMixin
include Queries::Filters::Shared::MeValueFilter
##
# Return whether the current values object has a me value
def has_me_value?
@ -47,47 +48,12 @@ module Queries::WorkPackages::Filter::MeValueFilterMixin
principals
end
##
# 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
def me_label
I18n.t(:label_me)
end
##
# 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 no_me_values
sanitized_values = values.reject { |v| v == me_value_key }
sanitized_values = sanitized_values.reject { |v| v == User.current.id.to_s } if has_me_value?
sanitized_values
end
def me_value_key
::Queries::Filters::MeValue::KEY
end
end

@ -177,7 +177,8 @@ en:
choose_widget: 'Choose widget'
widgets:
title:
work_packages_assigned: 'Work packages assigned by me'
time_entries_current_user: 'Spent time (last 7 days)'
work_packages_assigned: 'Work packages assigned to me'
work_packages_created: 'Work packages created by me'
work_packages_watched: 'Work packages watched by me'
work_packages_calendar: 'Calendar'
@ -290,6 +291,7 @@ en:
label_activity_show_only_comments: "Show activities with comments only"
label_activity_show_all: "Show all activities"
label_total_progress: "%{percent}% Total progress"
label_total_amount: "Total: %{amount}"
label_updated_on: "updated on"
label_warning: "Warning"
label_work_package: "Work package"
@ -402,6 +404,11 @@ en:
requires: "requiring"
required: "required by"
time_entry:
activity: 'Activity'
comment: 'Comment'
hours: 'Hours'
watchers:
label_loading: loading watchers...
label_error_loading: An error occurred while loading the watchers

@ -1,8 +1,10 @@
# Group Time Entries
## Actions
| Link | Description | Condition |
|:-------------------:| -------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Link | Description | Condition |
|:-------------------:| -------------------------------------------------------------------- | ---------------------------------------------------------------- |
| updateImmediately | Directly perform edits on this time entry | **Permission**: 'edit time entries' or 'edit own time entries if the time entry belongs to the user |
| delete | Delete this time entry | **Permission**: 'edit time entries' or 'edit own time entries if the time entry belongs to the user |
None yet
@ -62,6 +64,14 @@ Depending on custom fields defined for time entries, additional properties might
"self": {
"href": "/api/v3/time_entries/1"
},
"updateImmediately": {
"href": "/api/v3/time_entries/1",
"method": "patch"
},
"delete": {
"href": "/api/v3/time_entries/1",
"method": "delete"
},
"project": {
"href": "/api/v3/projects/1",
"title": "Some project"
@ -174,6 +184,14 @@ Permanently deletes the specified time entry.
"self": {
"href": "/api/v3/time_entries/1"
},
"updateImmediately": {
"href": "/api/v3/time_entries/1",
"method": "patch"
},
"delete": {
"href": "/api/v3/time_entries/1",
"method": "delete"
},
"project": {
"href": "/api/v3/projects/1",
"title": "Some project"

@ -26,7 +26,7 @@
// See doc/COPYRIGHT.rdoc for more details.
//++
export type FilterOperator = '=' | '!*' | '!' | '~' | 'o';
export type FilterOperator = '=' | '!*' | '!' | '~' | 'o' | '>t-';
export interface ApiV3Filter {
[filter:string]:{ operator:FilterOperator, values:any };

@ -61,6 +61,9 @@ export class ApiV3Paths {
// /api/v3/priorities
public readonly priorities = new SimpleResourceCollection(this.apiV3Base, 'priorities');
// /api/v3/time_entries
public readonly time_entries = new SimpleResourceCollection(this.apiV3Base, 'time_entries');
// /api/v3/types
public readonly types = new Apiv3TypesPaths(this.apiV3Base);

@ -26,11 +26,10 @@
[id]="gridAreaId(area) + '-widgeted-' + area.guid"
[cdkDropListData]="area"
[cdkDropListConnectedTo]="gridAreaDropIds">
<div class="widget-box"
<div class="grid--area-content widget-box"
cdkDrag
(cdkDragStarted)="dragStart(area)"
(cdkDragEnded)="dragStop(area, $event)"
style="height: 100%">
(cdkDragEnded)="dragStop(area, $event)">
<div *cdkDragPlaceholder></div>
<div class="grid--widget-remove"
(click)="removeWidget(area)">

@ -39,6 +39,7 @@ import {WidgetWpAssignedComponent} from "core-app/modules/grids/widgets/wp-assig
import {WidgetWpCreatedComponent} from "core-app/modules/grids/widgets/wp-created/wp-created.component.ts";
import {WidgetWpWatchedComponent} from "core-app/modules/grids/widgets/wp-watched/wp-watched.component.ts";
import {WidgetWpCalendarComponent} from "core-app/modules/grids/widgets/wp-calendar/wp-calendar.component.ts";
import {WidgetTimeEntriesCurrentUserComponent} from "core-app/modules/grids/widgets/time-entries-current-user/time-entries-current-user.component";
import {GridWidgetsService} from "core-app/modules/grids/widgets/widgets.service";
import {AddGridWidgetService} from "core-app/modules/grids/widgets/add/add.service";
import {GridComponent} from "core-app/modules/grids/grid.component";
@ -60,7 +61,8 @@ import {GridAreaRowHeadersComponent} from "core-app/modules/grids/areas/row-head
DynamicModule.withComponents([WidgetWpAssignedComponent,
WidgetWpCreatedComponent,
WidgetWpWatchedComponent,
WidgetWpCalendarComponent]),
WidgetWpCalendarComponent,
WidgetTimeEntriesCurrentUserComponent]),
],
providers: [
{
@ -78,6 +80,7 @@ import {GridAreaRowHeadersComponent} from "core-app/modules/grids/areas/row-head
WidgetWpCreatedComponent,
WidgetWpWatchedComponent,
WidgetWpCalendarComponent,
WidgetTimeEntriesCurrentUserComponent,
AddGridWidgetModal,
GridColumnContextMenu,
@ -124,6 +127,10 @@ export function registerWidgets(injector:Injector) {
{
identifier: 'work_packages_calendar',
component: WidgetWpCalendarComponent
},
{
identifier: 'time_entries_current_user',
component: WidgetTimeEntriesCurrentUserComponent
}
];
});

@ -1,6 +1,6 @@
import {Component, HostBinding, Input, InjectionToken, Inject, Output, EventEmitter} from "@angular/core";
import {Component, HostBinding, Input, Output, EventEmitter} from "@angular/core";
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {CdkDragStart} from "@angular/cdk/drag-drop";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
export abstract class AbstractWidgetComponent {
@HostBinding('style.grid-column-start') gridColumnStart:number;
@ -9,16 +9,14 @@ export abstract class AbstractWidgetComponent {
@HostBinding('style.grid-row-end') gridRowEnd:number;
@Input() resource:GridWidgetResource;
@Output() cdkDragStart:EventEmitter<CdkDragStart> = new EventEmitter();
constructor(protected i18n:I18nService) { }
// TODO: check if still needed
public set widgetResource(resource:GridWidgetResource) {
this.gridColumnStart = resource.startColumn;
this.gridColumnEnd = resource.endColumn;
this.gridRowStart = resource.startRow;
this.gridRowEnd = resource.endRow;
}
public emitDragStart(event:CdkDragStart) {
this.cdkDragStart.emit(event);
}
}

@ -32,6 +32,8 @@ export class AddGridWidgetModal extends OpModalComponent {
title: this.i18n.t(`js.grid.widgets.title.${widget.identifier}`),
component: widget.component
};
}).sort((a, b) => {
return a.title.localeCompare(b.title);
});
}

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

@ -8,7 +8,7 @@ import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-build
})
export class WidgetWpAssignedComponent extends AbstractWidgetComponent implements OnInit {
public widgetHeading = 'Work packages assigned to me';
public text = { title: this.i18n.t('js.grid.widgets.title.work_packages_assigned') };
public queryProps:any;
public configuration = { "actionsColumnEnabled": false,
"columnMenuEnabled": false,
@ -21,7 +21,6 @@ export class WidgetWpAssignedComponent extends AbstractWidgetComponent implement
this.queryProps = {"columns[]":["id", "project", "type", "subject"],
"filters":filters.toJson()};
}
}

@ -1,6 +1,6 @@
<h3 class="widget-box--header" cdkDragHandle>
<i class="icon-context icon-calendar" aria-hidden="true"></i>
<span class="widget-box--header-title" [textContent]="widgetHeading"></span>
<span class="widget-box--header-title" [textContent]="text.title"></span>
</h3>
<wp-calendar [static]="true">

@ -33,5 +33,5 @@ import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget
templateUrl: './wp-calendar.component.html',
})
export class WidgetWpCalendarComponent extends AbstractWidgetComponent {
public widgetHeading = 'Calendar';
public text = { title: this.i18n.t('js.grid.widgets.title.work_packages_calendar') };
}

@ -7,7 +7,7 @@ import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-build
styleUrls: ['../wp-widget/wp-widget.component.css']
})
export class WidgetWpCreatedComponent extends AbstractWidgetComponent implements OnInit {
public widgetHeading = 'Work packages created by me';
public text = { title: this.i18n.t('js.grid.widgets.title.work_packages_created') };
public queryProps:any;
public configuration = { "actionsColumnEnabled": false,
"columnMenuEnabled": false,

@ -7,7 +7,7 @@ import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-build
styleUrls: ['../wp-widget/wp-widget.component.css']
})
export class WidgetWpWatchedComponent extends AbstractWidgetComponent implements OnInit {
public widgetHeading = 'Work packages watched by me';
public text = { title: this.i18n.t('js.grid.widgets.title.work_packages_watched') };
public queryProps:any;
public configuration = { "actionsColumnEnabled": false,
"columnMenuEnabled": false,

@ -1,17 +1,5 @@
.widget-box {
display: flex;
flex-direction: column;
height: 100%;
}
wp-embedded-table {
display: flex;
flex: 1 1 auto;
overflow: hidden;
}
:host {
display: flex;
flex-direction: column;
height: 100%;
}

@ -1,6 +1,6 @@
<h3 class="widget-box--header" cdkDragHandle>
<i class="icon-context icon-assigned-to-me" aria-hidden="true"></i>
<span class="widget-box--header-title" [textContent]="widgetHeading"></span>
<span class="widget-box--header-title" [textContent]="text.title"></span>
</h3>
<wp-embedded-table [queryProps]="queryProps"

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

@ -47,6 +47,7 @@ import {ProjectDmService} from 'core-app/modules/hal/dm-services/project-dm.serv
import {HalResourceSortingService} from "core-app/modules/hal/services/hal-resource-sorting.service";
import {HalAwareErrorHandler} from "core-app/modules/hal/services/hal-aware-error-handler";
import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service";
import {TimeEntryDmService} from './dm-services/time-entry-dm.service';
@NgModule({
imports: [
@ -60,16 +61,17 @@ import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service";
{ provide: HTTP_INTERCEPTORS, useClass: OpenProjectHeaderInterceptor, multi: true },
{ provide: APP_INITIALIZER, useFactory: initializeHalResourceConfig, deps: [HalResourceService], multi: true },
ConfigurationDmService,
GridDmService,
HelpTextDmService,
PayloadDmService,
ProjectDmService,
QueryDmService,
UserDmService,
QueryFormDmService,
RelationsDmService,
ProjectDmService,
RootDmService,
TimeEntryDmService,
TypeDmService,
GridDmService
UserDmService,
]
})
export class OpenprojectHalModule { }

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

@ -55,6 +55,7 @@ import {PostResource} from "core-app/modules/hal/resources/post-resource";
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInterface } = {
WorkPackage: {
@ -164,12 +165,15 @@ const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInter
},
GridWidget: {
cls: GridWidgetResource
},
TimeEntry: {
cls: TimeEntryResource
}
};
export function initializeHalResourceConfig(halResourceService:HalResourceService) {
return () => {
_.each(halResourceDefaultConfig, (value, key) => halResourceService.registerResource(key, value));
}
};
}

@ -110,7 +110,7 @@ module API
call = ::TimeEntries::DeleteService.new(time_entry: @time_entry, user: current_user).call
if call.success?
status 202
status 204
else
fail ::API::Errors::ErrorBase.create_and_merge_errors(call.errors)
end

@ -39,6 +39,24 @@ module API
defaults render_nil: true
link :updateImmediately do
next unless update_allowed?
{
href: api_v3_paths.time_entry(represented.id),
method: :patch
}
end
link :delete do
next unless update_allowed?
{
href: api_v3_paths.time_entry(represented.id),
method: :delete
}
end
property :id
property :comments,
@ -109,6 +127,15 @@ module API
def _type
'TimeEntry'
end
def update_allowed?
current_user_allowed_to(:edit_time_entries, context: represented.project) ||
represented.user_id == current_user.id && current_user_allowed_to(:edit_own_time_entries, context: represented.project)
end
def current_user_allowed_to(permission, context:)
current_user.allowed_to?(permission, context)
end
end
end
end

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

@ -46,13 +46,21 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do
let(:work_package) { time_entry.work_package }
let(:activity) { FactoryBot.build_stubbed(:time_entry_activity) }
let(:user) { FactoryBot.build_stubbed(:user) }
let(:current_user) { user }
let(:permissions) do
[:edit_time_entries]
end
let(:representer) do
described_class.create(time_entry, current_user: user, embed_links: true)
described_class.create(time_entry, current_user: current_user, embed_links: true)
end
subject { representer.to_json }
before do
allow(current_user)
.to receive(:allowed_to?) do |permission, context_project|
project == context_project && permissions.include?(permission)
end
allow(time_entry)
.to receive(:available_custom_fields)
.and_return([])
@ -146,6 +154,60 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do
.at_path("_links/customField#{custom_field.id}/href")
end
end
context 'when allowed to update' do
it_behaves_like 'has an untitled link' do
let(:link) { 'updateImmediately' }
let(:href) { api_v3_paths.time_entry(time_entry.id) }
let(:method) { :patch }
end
it_behaves_like 'has an untitled link' do
let(:link) { 'delete' }
let(:href) { api_v3_paths.time_entry(time_entry.id) }
let(:method) { :delete }
end
end
context 'when not allowed to update' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'updateImmediately' }
end
it_behaves_like 'has no link' do
let(:link) { 'delete' }
end
end
context 'when allowed to edit own and it is own' do
let(:permissions) { [:edit_own_time_entries] }
it_behaves_like 'has an untitled link' do
let(:link) { 'updateImmediately' }
let(:href) { api_v3_paths.time_entry(time_entry.id) }
let(:method) { :patch }
end
it_behaves_like 'has an untitled link' do
let(:link) { 'delete' }
let(:href) { api_v3_paths.time_entry(time_entry.id) }
let(:method) { :delete }
end
end
context 'when allowed to edit own and it is not own' do
let(:permissions) { [:edit_own_time_entries] }
let(:current_user) { FactoryBot.build_stubbed(:user) }
it_behaves_like 'has no link' do
let(:link) { 'updateImmediately' }
end
it_behaves_like 'has no link' do
let(:link) { 'delete' }
end
end
end
describe 'properties' do

@ -46,31 +46,58 @@ describe Queries::TimeEntries::TimeEntryQuery, type: :model do
end
context 'with a user filter' do
let(:values) { ['1'] }
before do
allow(Principal)
.to receive_message_chain(:in_visible_project, :pluck)
.with(:id)
.and_return([1])
instance.where('user_id', '=', ['1'])
end
subject do
instance.where('user_id', '=', values)
instance
end
describe '#results' do
it 'is the same as handwriting the query' do
expected = base_scope
.where(["time_entries.user_id IN (?)", ['1']])
.where(["time_entries.user_id IN (?)", values])
expect(instance.results.to_sql).to eql expected.to_sql
expect(subject.results.to_sql).to eql expected.to_sql
end
context 'with a me value' do
let(:values) { ['me'] }
it 'replaces the value to produce the query' do
expected = base_scope
.where(["time_entries.user_id IN (?)", [user.id.to_s]])
expect(subject.results.to_sql).to eql expected.to_sql
end
end
end
describe '#valid?' do
it 'is true' do
expect(instance).to be_valid
expect(subject).to be_valid
end
it 'is invalid if the filter is invalid' do
instance.where('user_id', '=', [''])
expect(instance).to be_invalid
context 'with a me value and being logged in' do
let(:values) { ['me'] }
it 'is valid' do
expect(subject).to be_valid
end
end
context 'with not existing values' do
let(:values) { [''] }
it 'is invalid' do
expect(subject).to be_invalid
end
end
end
end

@ -625,7 +625,7 @@ describe 'API v3 time_entry resource', type: :request do
end
it 'deleted the time entry' do
expect(subject.status).to eq(202)
expect(subject.status).to eq(204)
end
context 'when lacking permissions' do
@ -641,7 +641,7 @@ describe 'API v3 time_entry resource', type: :request do
shared_examples_for 'deletes the time_entry' do
it 'responds with HTTP No Content' do
expect(subject.status).to eq 202
expect(subject.status).to eq 204
end
it 'removes the time_entry from the DB' do
@ -664,7 +664,7 @@ describe 'API v3 time_entry resource', type: :request do
end
context 'with the user not being the author' do
let(:time_entry) { other_time_entry }
let(:time_entry) { other_time_entry }
context 'but permission to edit all time entries' do
let(:permissions) { %i(edit_time_entries view_time_entries view_work_packages) }

@ -14,6 +14,10 @@ module Components
area.find('.resizer').drag_to find("#grid--area-#{row}-#{column}")
end
def remove
area.find('.grid--widget-remove').click
end
def expect_to_exist
expect(page)
.to have_selector(*area_selector)

@ -49,10 +49,9 @@ module Pages
end
def add_widget(row_number, column_number, name)
# within top-right area, add an additional widget
within "#grid--area-#{row_number}-#{column_number}" do
find('.grid--widget-add').click
end
area = find("#grid--area-#{row_number}-#{column_number}")
area.hover
area.find('.grid--widget-add').click
within '.op-modal--portal' do
expect(page)

Loading…
Cancel
Save