Merge pull request #7954 from opf/feature/timelog_on_widget

Feature/timelog on widget
pull/7992/head
ulferts 5 years ago committed by GitHub
commit d08a441f46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      app/assets/stylesheets/content/_attributes_key_value.sass
  2. 4
      app/assets/stylesheets/content/_calendars.sass
  3. 11
      app/assets/stylesheets/content/_notifications.sass
  4. 3
      app/assets/stylesheets/content/_tooltip.sass
  5. 3
      app/assets/stylesheets/content/_types_form_configuration.sass
  6. 6
      app/assets/stylesheets/content/work_packages/inplace_editing/_display_fields.sass
  7. 6
      app/assets/stylesheets/openproject/_mixins.sass
  8. 18
      app/contracts/time_entries/base_contract.rb
  9. 45
      app/models/time_entry/scopes/of_user_and_day.rb
  10. 8
      app/services/api/v3/work_package_collection_from_query_params_service.rb
  11. 14
      app/services/api/v3/work_package_collection_from_query_service.rb
  12. 2
      app/views/layouts/base.html.erb
  13. 4
      config/locales/en.yml
  14. 7
      config/locales/js-en.yml
  15. 3
      frontend/angular.json
  16. 13
      frontend/npm-shrinkwrap.json
  17. 2
      frontend/package.json
  18. 2
      frontend/src/app/angular4-modules.ts
  19. 4
      frontend/src/app/components/states.service.ts
  20. 71
      frontend/src/app/components/time-entries/time-entry-cache.service.ts
  21. 25
      frontend/src/app/components/time-entries/time-entry-changeset.ts
  22. 19
      frontend/src/app/components/user/user-avatar/user-avatar.component.ts
  23. 3
      frontend/src/app/components/wp-buttons/wp-status-button/wp-status-button.component.sass
  24. 3
      frontend/src/app/components/wp-card-view/wp-single-card/wp-single-card.component.sass
  25. 14
      frontend/src/app/modules/calendar/openproject-calendar.module.ts
  26. 92
      frontend/src/app/modules/calendar/te-calendar/te-calendar.component.sass
  27. 471
      frontend/src/app/modules/calendar/te-calendar/te-calendar.component.ts
  28. 24
      frontend/src/app/modules/calendar/te-calendar/te-calendar.template.html
  29. 2
      frontend/src/app/modules/calendar/wp-calendar/wp-calendar.component.ts
  30. 5
      frontend/src/app/modules/common/autocomplete/create-autocompleter.component.html
  31. 10
      frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts
  32. 40
      frontend/src/app/modules/common/autocomplete/wp-autocompleter.component.ts
  33. 21
      frontend/src/app/modules/common/colors/colors.service.ts
  34. 10
      frontend/src/app/modules/common/openproject-common.module.ts
  35. 9
      frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths.ts
  36. 3
      frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entry-paths.ts
  37. 14
      frontend/src/app/modules/fields/changeset/resource-changeset.ts
  38. 9
      frontend/src/app/modules/fields/display/display-field.initializer.ts
  39. 69
      frontend/src/app/modules/fields/display/field-types/linked-work-package-display-field.module.ts
  40. 40
      frontend/src/app/modules/fields/display/field-types/plain-formattable-display-field.module.ts
  41. 33
      frontend/src/app/modules/fields/display/field-types/work-package-display-field.module.ts
  42. 10
      frontend/src/app/modules/fields/edit/edit-field.initializer.ts
  43. 2
      frontend/src/app/modules/fields/edit/edit-form/edit-form.component.ts
  44. 2
      frontend/src/app/modules/fields/edit/editing-portal/edit-form-portal.component.ts
  45. 51
      frontend/src/app/modules/fields/edit/field-types/plain-formattable-edit-field.component.ts
  46. 96
      frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts
  47. 44
      frontend/src/app/modules/fields/edit/field-types/te-work-package-edit-field.component.ts
  48. 10
      frontend/src/app/modules/fields/edit/field-types/text-edit-field.component.html
  49. 13
      frontend/src/app/modules/fields/edit/field-types/text-edit-field.component.ts
  50. 13
      frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html
  51. 64
      frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.ts
  52. 4
      frontend/src/app/modules/fields/edit/field/editable-attribute-field.component.ts
  53. 6
      frontend/src/app/modules/fields/openproject-fields.module.ts
  54. 2
      frontend/src/app/modules/global_search/input/global-search-input.component.ts
  55. 4
      frontend/src/app/modules/grids/openproject-grids.module.ts
  56. 1
      frontend/src/app/modules/grids/widgets/add/add.modal.ts
  57. 13
      frontend/src/app/modules/grids/widgets/time-entries-current-user/current-user/time-entries-current-user.component.ts
  58. 18
      frontend/src/app/modules/grids/widgets/time-entries/current-user/time-entries-current-user.component.html
  59. 45
      frontend/src/app/modules/grids/widgets/time-entries/current-user/time-entries-current-user.component.ts
  60. 0
      frontend/src/app/modules/grids/widgets/time-entries/list/time-entries-list.component.html
  61. 4
      frontend/src/app/modules/grids/widgets/time-entries/list/time-entries-list.component.ts
  62. 2
      frontend/src/app/modules/grids/widgets/time-entries/project/time-entries-project.component.ts
  63. 1
      frontend/src/app/modules/grids/widgets/wp-graph/wp-graph.component.ts
  64. 46
      frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts
  65. 2
      frontend/src/app/modules/hal/resources/hal-resource.ts
  66. 43
      frontend/src/app/modules/hal/resources/time-entry-resource.ts
  67. 37
      frontend/src/app/modules/time_entries/create/create.modal.html
  68. 57
      frontend/src/app/modules/time_entries/create/create.modal.ts
  69. 80
      frontend/src/app/modules/time_entries/create/create.service.ts
  70. 40
      frontend/src/app/modules/time_entries/edit/edit.modal.html
  71. 9
      frontend/src/app/modules/time_entries/edit/edit.modal.sass
  72. 56
      frontend/src/app/modules/time_entries/edit/edit.modal.ts
  73. 40
      frontend/src/app/modules/time_entries/edit/edit.service.ts
  74. 56
      frontend/src/app/modules/time_entries/form/form.component.html
  75. 82
      frontend/src/app/modules/time_entries/form/form.component.ts
  76. 60
      frontend/src/app/modules/time_entries/openproject-time-entries.module.ts
  77. 12
      frontend/src/app/modules/work_packages/openproject-work-packages.module.ts
  78. 51
      lib/api/v3/time_entries/available_work_packages_helper.rb
  79. 54
      lib/api/v3/time_entries/available_work_packages_on_create_api.rb
  80. 57
      lib/api/v3/time_entries/available_work_packages_on_edit_api.rb
  81. 16
      lib/api/v3/time_entries/schemas/time_entry_schema_representer.rb
  82. 2
      lib/api/v3/time_entries/time_entries_api.rb
  83. 25
      lib/api/v3/time_entries/time_entry_representer.rb
  84. 8
      lib/api/v3/utilities/path_helper.rb
  85. 1
      modules/boards/spec/features/action_boards/status_type_moving_board_spec.rb
  86. 2
      modules/dashboards/spec/features/time_entries_spec.rb
  87. 2
      modules/grids/config/locales/js-en.yml
  88. 206
      modules/my_page/spec/features/my/time_entries_current_user_spec.rb
  89. 27
      spec/contracts/time_entries/shared_contract_examples.rb
  90. 45
      spec/lib/api/v3/time_entries/schemas/time_entry_schema_representer_spec.rb
  91. 28
      spec/lib/api/v3/time_entries/time_entry_representer_parsing_spec.rb
  92. 15
      spec/lib/api/v3/time_entries/time_entry_representer_rendering_spec.rb
  93. 14
      spec/models/changeset_spec.rb
  94. 74
      spec/models/time_entry/scopes/of_user_and_day_spec.rb
  95. 2
      spec/requests/api/v3/time_entries/create_form_resource_spec.rb
  96. 2
      spec/requests/api/v3/time_entries/update_form_resource_spec.rb
  97. 2
      spec/services/api/v3/work_package_collection_from_query_params_service_spec.rb
  98. 10
      spec/support/edit_fields/edit_field.rb

@ -94,5 +94,4 @@
.attributes-map--value
// empty for now but it makes sense to also have it present in the style declaration IMO
zoom: 1
@include text-shortener
white-space: normal
@include text-shortener(false)

@ -21,6 +21,10 @@ full-calendar
.fc-button-active
@extend .button.-active
.fc-title
max-width: 100%
@include text-shortener(false)
.fc-header-toolbar
h2
@extend h2

@ -202,13 +202,17 @@ $nm-upload-box-padding: rem-calc(15) rem-calc(25)
right: 10%
.notification-box--wrapper
z-index: 901 // Higher than loading indicator!
z-index: 10000 // Higher than loading indicator and modal wrapper!
top: 4rem
.notification-box--casing
position: relative
.notification-box
margin-bottom: rem-calc(3px)
.flash, #errorExplanation
top: 1rem
// awesome animations
.notification-box
&.ng-enter
@ -356,11 +360,6 @@ a.notification-box--target-link
cursor: pointer
text-decoration: underline
.flash,
#errorExplanation,
.notification-box--wrapper
top: 1rem
#errorExplanation
@extend %error-placeholder
top: rem-calc(68px)

@ -43,6 +43,9 @@
margin-left: 0
font-size: 13.6px
&:first-child
margin-top: 0
.tooltip--map--key
font-weight: bold

@ -94,8 +94,7 @@
.attribute-name,
.group-name
flex-basis: 90%
@include text-shortener
white-space: normal
@include text-shortener(false)
// Query group styles

@ -43,8 +43,7 @@
.inline-edit--display-field
display: inline-block
max-width: 100%
@include text-shortener
white-space: inherit
@include text-shortener(false)
&.-placeholder
font-style: italic
@ -52,8 +51,7 @@
// Always render custom options as inline
// when only one line
.custom-option
@include text-shortener
white-space: normal
@include text-shortener(false)
&:not(.-multiple-lines)
display: inline

@ -43,12 +43,14 @@
height: 0
clear: both
@mixin text-shortener
white-space: nowrap
@mixin text-shortener($oneline: true)
overflow: hidden
text-overflow: ellipsis
-o-text-overflow: ellipsis
-ms-text-overflow: ellipsis
@if $oneline
white-space: nowrap
@mixin allow-vertical-scrolling
overflow-x: hidden

@ -58,7 +58,9 @@ module TimeEntries
attribute :activity_id do
validate_activity_active
end
attribute :hours
attribute :hours do
validate_day_limit
end
attribute :comments
attribute_alias :comments, :comment
@ -98,6 +100,14 @@ module TimeEntries
errors.add :activity_id, :inclusion if model.activity_id && !assignable_activities.exists?(model.activity_id)
end
def validate_day_limit
return unless model.spent_on && model.hours
if hours_extending_day_limit?
errors.add :hours, :day_limit
end
end
def work_package_invisible?
model.work_package.nil? || !model.work_package.visible?(user)
end
@ -105,5 +115,11 @@ module TimeEntries
def work_package_not_in_project?
model.work_package && model.project != model.work_package.project
end
def hours_extending_day_limit?
existing_sum = TimeEntry::Scopes::OfUserAndDay.fetch(model.user, model.spent_on, excluding: model).sum(:hours)
existing_sum + model.hours > 24
end
end
end

@ -0,0 +1,45 @@
#-- 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 TimeEntry::Scopes
class OfUserAndDay
def self.fetch(user, date, excluding: nil)
scope = TimeEntry
.where(spent_on: date,
user: user)
if excluding
scope = scope.where.not(id: excluding.id)
end
scope
end
end
end

@ -29,21 +29,23 @@
module API
module V3
class WorkPackageCollectionFromQueryParamsService
def initialize(user)
def initialize(user, scope: nil)
self.current_user = user
self.scope = scope
end
def call(params = {})
query = Query.new_default(name: '_', project: params[:project])
WorkPackageCollectionFromQueryService
.new(query, current_user)
.new(query, current_user, scope: scope)
.call(params)
end
private
attr_accessor :current_user
attr_accessor :current_user,
:scope
end
end
end

@ -32,9 +32,10 @@ module API
include Utilities::PathHelper
include ::API::Utilities::PageSizeHelper
def initialize(query, user)
def initialize(query, user, scope: nil)
self.query = query
self.current_user = user
self.scope = scope
end
def call(params = {}, valid_subset: false)
@ -54,7 +55,13 @@ module API
private
def results_to_representer(params)
collection_representer(query.results.sorted_work_packages,
results_scope = query.results.sorted_work_packages
if scope
results_scope = results_scope.where(id: scope.select(:id))
end
collection_representer(results_scope,
params: params,
project: query.project,
groups: generate_groups,
@ -62,7 +69,8 @@ module API
end
attr_accessor :query,
:current_user
:current_user,
:scope
def representer
::API::V3::WorkPackages::WorkPackageCollectionRepresenter

@ -118,6 +118,7 @@ See docs/COPYRIGHT.rdoc for more details.
</p>
</div>
</noscript>
<notifications-container></notifications-container>
<% main_menu = render_main_menu(local_assigns.fetch(:menu_name, nil), @project) %>
<% side_displayed = content_for?(:sidebar) || content_for?(:main_menu) || !main_menu.blank? %>
<% initial_classes = initial_menu_classes(side_displayed, show_decoration) %>
@ -175,7 +176,6 @@ See docs/COPYRIGHT.rdoc for more details.
</div>
<% end %>
<%= render_flash_messages %>
<notifications-container></notifications-container>
<% if show_onboarding_modal? %>
<section data-augmented-model-wrapper
data-modal-initialize-now="true"

@ -647,6 +647,10 @@ en:
role:
permissions:
dependency_missing: "need to also include '%{dependency}' as '%{permission}' is selected."
time_entry:
attributes:
hours:
day_limit: "is too high as a maximum of 24 hours can be logged per date."
work_package:
is_not_a_valid_target_for_time_entries: "Work package #%{id} is not a valid target for reassigning the time entries."
attributes:

@ -503,9 +503,16 @@ en:
settings: "Settings"
time_entry:
project: 'Project'
work_package: 'Work package'
work_package_required: 'Requires selecting a work package first.'
activity: 'Activity'
comment: 'Comment'
duration: 'Duration'
spent_on: 'Date'
hours: 'Hours'
edit: 'Edit time entry'
create: 'Create time entry'
two_factor_authentication:
label_two_factor_authentication: 'Two-factor authentication'

@ -28,7 +28,8 @@
"node_modules/jquery-ui/themes/base/datepicker.css",
"node_modules/jquery-ui/themes/base/dialog.css",
"node_modules/@fullcalendar/core/main.css",
"node_modules/@fullcalendar/daygrid/main.css"
"node_modules/@fullcalendar/daygrid/main.css",
"node_modules/@fullcalendar/timegrid/main.css"
],
"stylePreprocessorOptions": {
"includePaths": [

@ -3355,6 +3355,19 @@
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-4.3.0.tgz",
"integrity": "sha512-OrGaRIGbELESOXOCXtoQVY4cOxnxmN7OrjDGbMoVITdoXWuIZ6sFNO84WcBQoRaIbkmqVM0SAU2MENwy+eEwqQ=="
},
"@fullcalendar/interaction": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-4.3.0.tgz",
"integrity": "sha512-kaKV+tdgH/oIrwTSMGYFec989L5r+KMYJ9ybwLc8G3LbMVcebo8fAlN9VizmGQAuopKfyvOw8yzJdjfmVCCRYQ=="
},
"@fullcalendar/timegrid": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-4.3.0.tgz",
"integrity": "sha512-9JCSZbzw2Hi+X893l5X9ViZaWjxh+sDy59o6nKx0gYKwSfs/vKpmk73GBQfKyUv8orkjpnTdPFfeLDZqlzBP4Q==",
"requires": {
"@fullcalendar/daygrid": "~4.3.0"
}
},
"@istanbuljs/schema": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz",

@ -45,6 +45,8 @@
"@fullcalendar/angular": "4.3.1",
"@fullcalendar/core": "4.3.1",
"@fullcalendar/daygrid": "4.3.0",
"@fullcalendar/timegrid": "4.3.0",
"@fullcalendar/interaction": "4.3.0",
"@ng-select/ng-option-highlight": "0.0.2",
"@ng-select/ng-select": "^3.0.6",
"@sentry/browser": "^5.7.1",

@ -94,6 +94,7 @@ import {OpenprojectOverviewModule} from "core-app/modules/overview/openproject-o
import {OpenprojectMyPageModule} from "core-app/modules/my-page/openproject-my-page.module";
import {OpenprojectProjectsModule} from "core-app/modules/projects/openproject-projects.module";
import {OpenprojectIFCModelsModule} from "core-app/modules/ifc_models/openproject-ifc-models.module";
import {TimeEntryCacheService} from "core-components/time-entries/time-entry-cache.service";
@NgModule({
imports: [
@ -164,6 +165,7 @@ import {OpenprojectIFCModelsModule} from "core-app/modules/ifc_models/openprojec
OpTitleService,
UrlParamsHelperService,
ProjectCacheService,
TimeEntryCacheService,
FormsCacheService,
UserCacheService,
StatusCacheService,

@ -14,6 +14,7 @@ import {QuerySortByResource} from "core-app/modules/hal/resources/query-sort-by-
import {QueryGroupByResource} from "core-app/modules/hal/resources/query-group-by-resource";
import {VersionResource} from "core-app/modules/hal/resources/version-resource";
import {wpDisplayRepresentation} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-display-representation.service";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
export class States extends StatesGroup {
name = 'MainStore';
@ -36,6 +37,9 @@ export class States extends StatesGroup {
/* /api/v3/statuses */
statuses = multiInput<StatusResource>();
/* /api/v3/time_entries */
timeEntries:MultiInputState<TimeEntryResource> = multiInput<TimeEntryResource>();
/* /api/v3/versions */
versions = multiInput<VersionResource>();

@ -0,0 +1,71 @@
// -- 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 {MultiInputState} from 'reactivestates';
import {States} from '../states.service';
import {StateCacheService} from '../states/state-cache.service';
import {Injectable} from '@angular/core';
import {SchemaCacheService} from "core-components/schemas/schema-cache.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";
@Injectable()
export class TimeEntryCacheService extends StateCacheService<TimeEntryResource> {
constructor(readonly states:States,
readonly schemaCacheService:SchemaCacheService,
readonly timeEntryDm:TimeEntryDmService) {
super();
}
protected loadAll(ids:string[]):Promise<undefined> {
return Promise
.all(ids.map(id => this.load(id)))
.then(_ => undefined);
}
updateValue(id:string, val:TimeEntryResource) {
this.schemaCacheService.ensureLoaded(val).then(() => {
super.updateValue(id, val);
});
}
protected load(id:string):Promise<TimeEntryResource> {
return this
.timeEntryDm
.one(parseInt(id))
.then((timeEntry) => {
return this.schemaCacheService.ensureLoaded(timeEntry).then(() => timeEntry);
});
}
protected get multiState():MultiInputState<TimeEntryResource> {
return this.states.timeEntries;
}
}

@ -0,0 +1,25 @@
import {ResourceChangeset} from "core-app/modules/fields/changeset/resource-changeset";
import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource';
export class TimeEntryChangeset extends ResourceChangeset<TimeEntryResource> {
public setValue(key:string, val:any) {
super.setValue(key, val);
// Update the form for fields that may alter the form itself
// when the time entry is new. Otherwise, the save request afterwards
// will update the form automatically.
if (this.pristineResource.isNew && (key === 'workPackage')) {
this.updateForm().then(() => this.push());
}
}
protected buildPayloadFromChanges() {
let payload = super.buildPayloadFromChanges();
// we ignore the project and instead rely completely on the work package.
delete payload['_links']['project'];
return payload;
}
}

@ -39,6 +39,7 @@ import {
} from "@angular/core";
import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {ColorsService} from "core-app/modules/common/colors/colors.service";
@Component({
selector: 'user-avatar',
@ -60,7 +61,8 @@ export class UserAvatarComponent implements AfterViewInit {
constructor(protected elementRef:ElementRef,
protected ref:ChangeDetectorRef,
protected pathHelper:PathHelperService) {
protected pathHelper:PathHelperService,
protected colors:ColorsService) {
}
public ngAfterViewInit() {
@ -87,12 +89,12 @@ export class UserAvatarComponent implements AfterViewInit {
this.useFallback = element.dataset.useFallback!;
this.userAvatarUrl = this.pathHelper.api.v3.users.id(this.userId).avatar.toString();
this.userInitials = this.getInitials(this.userName);
this.colorCode = this.computeColor(this.userName);
this.colorCode = this.colors.toHsl(this.userName);
this.ref.detectChanges();
}
private getInitials(name:string) {
var names = name.split(' '),
let names = name.split(' '),
initials = names[0].substring(0, 1).toUpperCase();
if (names.length > 1) {
@ -101,17 +103,6 @@ export class UserAvatarComponent implements AfterViewInit {
return initials;
}
private computeColor(name:string) {
let hash = 0;
for (var i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
let h = hash % 360;
return 'hsl(' + h + ', 50%, 50%)';
}
}
DynamicBootstrapper.register({ selector: 'user-avatar', cls: UserAvatarComponent });

@ -2,13 +2,12 @@
.wp-status-button
.button
@include text-shortener
@include text-shortener(false)
padding: 5px
margin-bottom: 0
background-color: var(--status-selector-bg-color)
border: none
color: white
white-space: nowrap
max-width: 100%
.button--text

@ -45,8 +45,7 @@
grid-area: type
.wp-card--subject
grid-area: subject
@include text-shortener
white-space: normal
@include text-shortener(false)
.wp-card--assignee
grid-area: avatar
place-self: center left

@ -33,6 +33,9 @@ import {WorkPackagesCalendarEntryComponent} from "core-app/modules/calendar/wp-c
import {WorkPackagesCalendarController} from "core-app/modules/calendar/wp-calendar/wp-calendar.component";
import {OpenprojectWorkPackagesModule} from "core-app/modules/work_packages/openproject-work-packages.module";
import {Ng2StateDeclaration, UIRouterModule} from "@uirouter/angular";
import {TimeEntryCalendarComponent} from "core-app/modules/calendar/te-calendar/te-calendar.component";
import {OpenprojectFieldsModule} from "core-app/modules/fields/openproject-fields.module";
import {OpenprojectTimeEntriesModule} from "core-app/modules/time_entries/openproject-time-entries.module";
const menuItemClass = 'calendar-menu-item';
@ -61,6 +64,12 @@ export const CALENDAR_ROUTES:Ng2StateDeclaration[] = [
// Work Package module
OpenprojectWorkPackagesModule,
// Time entry module
OpenprojectTimeEntriesModule,
// Editable fields e.g. for modals
OpenprojectFieldsModule,
// Calendar component
FullCalendarModule,
],
@ -68,13 +77,16 @@ export const CALENDAR_ROUTES:Ng2StateDeclaration[] = [
// Work package calendars
WorkPackagesCalendarEntryComponent,
WorkPackagesCalendarController,
TimeEntryCalendarComponent,
],
entryComponents: [
WorkPackagesCalendarController,
WorkPackagesCalendarEntryComponent,
TimeEntryCalendarComponent,
],
exports: [
WorkPackagesCalendarController
WorkPackagesCalendarController,
TimeEntryCalendarComponent,
]
})
export class OpenprojectCalendarModule {

@ -0,0 +1,92 @@
.fc-view-container
overflow-x: auto
.fc-view
min-width: 800px
.fc-head
display: table-footer-group
.fc-event
border-radius: 0
margin-right: 8px
margin-left: 8px
.fc-head-container
border-color: white
.fc-day-header
padding-top: 5px
.fc-widget-content
border-left-color: white
.fc-day
border-left: 0
border-right: 0
.fc-timeGrid-view .fc-day-grid .fc-row
min-height: 1em
.fc-timeGrid-view .fc-day-grid .fc-row .fc-content-skeleton
padding-bottom: 0
.fc-time-grid .fc-slats .fc-minor td
border-top: 0
.te-calendar--day-sum
border: none
background-color: initial
color: #000000
text-align: center
font-size: 1em
font-weight: bold
.fc-title
// as this is the height of the day sum element
line-height: 22px
.te-calendar--add-entry
text-align: center
font-weight: bold
opacity: 0
background: none
border: none
&:hover
opacity: 1
transition: opacity 1s ease
background: #EAEAEA
.fc-content
position: sticky
color: black
width: 100%
top: 200px
font-weight: normal
font-size: 1.5rem
.te-calendar--time-entry
.fc-content
height: 100%
.fc-fadeout
position: relative
bottom: 2em
height: 2em
z-index: 5
&.fc-short
.fc-fadeout
display: none
.fc-duration
border-right: 1px solid white
border-bottom: 1px solid white
margin-left: -1px
padding-left: 1px
display: inline-block
margin-right: 5px
padding-right: 5px
font-weight: bold

@ -0,0 +1,471 @@
import {Component, ElementRef, Input, OnDestroy, OnInit, SecurityContext, ViewChild, AfterViewInit, Output, EventEmitter, Injector, ViewEncapsulation, ChangeDetectionStrategy} from "@angular/core";
import {FullCalendarComponent} from '@fullcalendar/angular';
import {States} from "core-components/states.service";
import * as moment from "moment";
import { Moment } from 'moment';
import {StateService} from "@uirouter/core";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {NotificationsService} from "core-app/modules/common/notifications/notifications.service";
import {DomSanitizer} from "@angular/platform-browser";
import timeGrid from '@fullcalendar/timegrid';
import { EventInput, EventApi, Duration, View } from '@fullcalendar/core';
import { EventSourceError } from '@fullcalendar/core/structs/event-source';
import { ToolbarInput } from '@fullcalendar/core/types/input-types';
import {ConfigurationService} from "core-app/modules/common/config/configuration.service";
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {TimezoneService} from "core-components/datetime/timezone.service";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {TimeEntryCacheService} from "core-components/time-entries/time-entry-cache.service";
import interactionPlugin from '@fullcalendar/interaction';
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {TimeEntryEditService} from "core-app/modules/time_entries/edit/edit.service";
import {TimeEntryCreateService} from "core-app/modules/time_entries/create/create.service";
import {ColorsService} from "core-app/modules/common/colors/colors.service";
import {BrowserDetector} from "core-app/modules/common/browser/browser-detector.service";
interface CalendarViewEvent {
el:HTMLElement;
event:EventApi;
jsEvent:MouseEvent;
}
interface CalendarMoveEvent {
el:HTMLElement;
event:EventApi;
oldEvent:EventApi;
delta:Duration;
revert:() => void;
jsEvent:Event;
view:View;
}
const TIME_ENTRY_CLASS_NAME = 'te-calendar--time-entry';
const DAY_SUM_CLASS_NAME = 'te-calendar--day-sum';
const ADD_ENTRY_CLASS_NAME = 'te-calendar--add-entry';
@Component({
templateUrl: './te-calendar.template.html',
styleUrls: ['./te-calendar.component.sass'],
selector: 'te-calendar',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
TimeEntryEditService,
TimeEntryCreateService,
HalResourceEditingService
]
})
export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild(FullCalendarComponent, { static: false }) ucCalendar:FullCalendarComponent;
@Input() projectIdentifier:string;
@Input() static:boolean = false;
@Output() entries = new EventEmitter<CollectionResource<TimeEntryResource>>();
public calendarPlugins = [timeGrid, interactionPlugin];
public calendarEvents:Function;
public calendarHeader:ToolbarInput|boolean = {
right: '',
center: 'title',
left: 'prev,next today'
};
public calendarSlotLabelFormat = (info:any) => 24 - info.date.hour;
public calendarScrollTime = '24:00:00';
public calendarContentHeight = 545;
public calendarAllDaySlot = false;
public calendarAllDayText = '';
public calendarDisplayEventTime = false;
public calendarSlotEventOverlap = false;
public calendarEditable = false;
public calendarEventOverlap = (stillEvent:any) => !stillEvent.classNames.includes(TIME_ENTRY_CLASS_NAME);
protected memoizedTimeEntries:{start:Date, end:Date, entries:Promise<CollectionResource<TimeEntryResource>>};
protected memoizedCreateAllowed:boolean = false;
constructor(readonly states:States,
readonly timeEntryDm:TimeEntryDmService,
readonly $state:StateService,
private element:ElementRef,
readonly i18n:I18nService,
readonly injector:Injector,
readonly notificationsService:NotificationsService,
private sanitizer:DomSanitizer,
private configuration:ConfigurationService,
private timezone:TimezoneService,
private timeEntryEdit:TimeEntryEditService,
private timeEntryCreate:TimeEntryCreateService,
private timeEntryCache:TimeEntryCacheService,
private colors:ColorsService,
private browserDetector:BrowserDetector) { }
ngOnInit() {
this.initializeCalendar();
}
ngOnDestroy() {
// nothing to do
}
ngAfterViewInit() {
// The full-calendar component's outputs do not seem to work
// see: https://github.com/fullcalendar/fullcalendar-angular/issues/228#issuecomment-523505044
// Therefore, setting the outputs via the underlying API
this.ucCalendar.getApi().setOption('eventRender', (event:CalendarViewEvent) => { this.alterEventEntry(event); });
this.ucCalendar.getApi().setOption('eventDestroy', (event:CalendarViewEvent) => { this.beforeEventRemove(event); });
this.ucCalendar.getApi().setOption('eventClick', (event:CalendarViewEvent) => { this.dispatchEventClick(event); });
this.ucCalendar.getApi().setOption('eventDrop', (event:CalendarMoveEvent) => { this.moveEvent(event); });
}
public calendarEventsFunction(fetchInfo:{ start:Date, end:Date },
successCallback:(events:EventInput[]) => void,
failureCallback:(error:EventSourceError) => void ):void | PromiseLike<EventInput[]> {
this.fetchTimeEntries(fetchInfo.start, fetchInfo.end)
.then((collection) => {
this.entries.emit(collection);
successCallback(this.buildEntries(collection.elements, fetchInfo));
});
}
protected fetchTimeEntries(start:Date, end:Date) {
if (!this.memoizedTimeEntries ||
this.memoizedTimeEntries.start.getTime() !== start.getTime() ||
this.memoizedTimeEntries.end.getTime() !== end.getTime()) {
let promise = this
.timeEntryDm
.list({ filters: this.dmFilters(start, end), pageSize: 500 })
.then(collection => {
this.memoizedCreateAllowed = !!collection.createTimeEntry;
collection.elements.forEach(timeEntry => this.timeEntryCache.updateValue(timeEntry.id!, timeEntry));
return collection;
});
this.memoizedTimeEntries = { start: start, end: end, entries: promise };
}
return this.memoizedTimeEntries.entries;
}
private buildEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }) {
return this.buildTimeEntryEntries(entries)
.concat(this.buildAuxEntries(entries, fetchInfo));
}
private buildTimeEntryEntries(entries:TimeEntryResource[]) {
let hoursDistribution:{ [key:string]:Moment } = {};
return entries.map((entry) => {
let start:Moment;
let end:Moment;
let hours = this.timezone.toHours(entry.hours);
if (hoursDistribution[entry.spentOn]) {
start = hoursDistribution[entry.spentOn].clone().subtract(hours, 'h');
end = hoursDistribution[entry.spentOn].clone();
} else {
start = moment(entry.spentOn).add(24 - hours, 'h');
end = moment(entry.spentOn).add(24, 'h');
}
hoursDistribution[entry.spentOn] = start;
const color = this.colors.toHsl(this.entryName(entry));
return this.timeEntry(entry, hours, start, end);
}) as EventInput[];
}
private buildAuxEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }) {
let dateSums:{ [key:string]:number } = {};
entries.forEach((entry) => {
let hours = this.timezone.toHours(entry.hours);
if (dateSums[entry.spentOn]) {
dateSums[entry.spentOn] += hours;
} else {
dateSums[entry.spentOn] = hours;
}
});
let calendarEntries:EventInput[] = [];
for (let m = moment(fetchInfo.start); m.diff(fetchInfo.end, 'days') <= 0; m.add(1, 'days')) {
let duration = dateSums[m.format('YYYY-MM-DD')] || 0;
calendarEntries.push(this.sumEntry(m, duration));
if (this.memoizedCreateAllowed && duration < 24) {
calendarEntries.push(this.addEntry(m, duration));
}
}
return calendarEntries;
}
protected timeEntry(entry:TimeEntryResource, hours:number, start:Moment, end:Moment) {
const color = this.colors.toHsl(this.entryName(entry));
return {
title: hours < 0.5 ? '' : this.entryName(entry),
startEditable: !!entry.update,
start: start.format(),
end: end.format(),
backgroundColor: color,
borderColor: color,
classNames: TIME_ENTRY_CLASS_NAME,
entry: entry
};
}
protected sumEntry(date:Moment, duration:number) {
return {
title: this.i18n.t('js.units.hour', { count: this.formatNumber(duration) }),
start: date.clone().add(24 - Math.min(duration, 23.5) - 0.5, 'h').format(),
end: date.clone().add(24 - Math.min(duration, 23.5), 'h').format(),
classNames: DAY_SUM_CLASS_NAME
};
}
protected addEntry(date:Moment, duration:number) {
return {
title: '+',
start: date.clone().format(),
end: date.clone().add(24 - Math.min(duration, 22.5) - 0.5, 'h').format(),
classNames: ADD_ENTRY_CLASS_NAME
};
}
protected dmFilters(start:Date, end:Date):Array<[string, FilterOperator, string[]]> {
let startDate = moment(start).format('YYYY-MM-DD');
let endDate = moment(end).subtract(1, 'd').format('YYYY-MM-DD');
return [['spentOn', '<>d', [startDate, endDate]] as [string, FilterOperator, string[]],
['user_id', '=', ['me']] as [string, FilterOperator, [string]]];
}
private initializeCalendar() {
this.calendarEvents = this.calendarEventsFunction.bind(this);
}
public get calendarEventLimit() {
return false;
}
public get calendarLocale() {
return this.i18n.locale;
}
public get calendarFixedWeekCount() {
return false;
}
public get calendarDefaultView() {
return 'timeGridWeek';
}
public get calendarFirstDay() {
return this.configuration.startOfWeek();
}
private get calendarElement() {
return jQuery(this.element.nativeElement).find('.fc-view-container');
}
private dispatchEventClick(event:CalendarViewEvent) {
if (event.event.extendedProps.entry) {
this.editEvent(event.event.extendedProps.entry);
} else if (event.event.start) {
this.addEvent(moment(event.event.start));
}
}
private editEvent(entry:TimeEntryResource) {
this
.timeEntryEdit
.edit(entry)
.then(modificationAction => {
this.updateEventSet(modificationAction.entry, modificationAction.action);
})
.catch(() => {
// do nothing, the user closed without changes
});
}
private moveEvent(event:CalendarMoveEvent) {
let entry = event.event.extendedProps.entry;
entry.spentOn = moment(event.event.start!).format('YYYY-MM-DD');
this
.timeEntryDm
.update(entry, entry.schema)
.then(event => {
this.updateEventSet(event, 'update');
})
.catch(() => {
event.revert();
});
}
private addEvent(date:Moment) {
if (!this.memoizedCreateAllowed) {
return;
}
this
.timeEntryCreate
.create(date)
.then(modificationAction => {
this.updateEventSet(modificationAction.entry, modificationAction.action);
})
.catch(() => {
// do nothing, the user closed without changes
});
}
private updateEventSet(event:TimeEntryResource, action:'update'|'destroy'|'create') {
this.memoizedTimeEntries.entries.then(collection => {
let foundIndex = collection.elements.findIndex(x => x.id === event.id);
switch (action) {
case 'update':
collection.elements[foundIndex] = event;
break;
case 'destroy':
collection.elements.splice(foundIndex, 1);
break;
case 'create':
collection.elements.push(event);
break;
}
this.ucCalendar.getApi().refetchEvents();
});
}
private alterEventEntry(event:CalendarViewEvent) {
if (!event.event.extendedProps.entry) {
return;
}
this.addTooltip(event);
this.prependDuration(event);
this.appendFadeout(event);
}
private addTooltip(event:CalendarViewEvent) {
if (this.browserDetector.isMobile) {
return;
}
jQuery(event.el).tooltip({
content: this.tooltipContentString(event.event.extendedProps.entry),
items: '.fc-event',
close: function () { jQuery(".ui-helper-hidden-accessible").remove(); },
track: true
});
}
private removeTooltip(event:CalendarViewEvent) {
jQuery(event.el).tooltip('disable');
}
private prependDuration(event:CalendarViewEvent) {
let formattedDuration = this.timezone.formattedDuration(event.event.extendedProps.entry.hours);
jQuery(event.el)
.find('.fc-title')
.prepend(`<div class="fc-duration">${formattedDuration}</div>`);
}
/* Fade out event text to the bottom to avoid it being cut of weirdly.
* Multiline ellipsis with an unknown height is not possible, hence we blur the text.
* The gradient needs to take the background color of the element into account (hashed over the event
* title) which is why the style is set in code.
*
* We do not print anything on short entries (< 0.5 hours),
* which leads to the fc-short class not being applied by full calendar. For other short events, the css rules
* need to deactivate the fc-fadeout.
*/
private appendFadeout(event:CalendarViewEvent) {
let timeEntry = event.event.extendedProps.entry;
if (this.timezone.toHours(timeEntry.hours) < 0.5) {
return;
}
let $element = jQuery(event.el);
let fadeout = jQuery(`<div class="fc-fadeout"></div>`);
let hslaStart = this.colors.toHsla(this.entryName(timeEntry), 0);
let hslaEnd = this.colors.toHsla(this.entryName(timeEntry), 100);
fadeout.css('background', `-webkit-linear-gradient(${hslaStart} 0%, ${hslaEnd} 100%`);
['-moz-linear-gradient', '-o-linear-gradient', 'linear-gradient', '-ms-linear-gradient'].forEach((style => {
fadeout.css('background-image', `${style}(${hslaStart} 0%, ${hslaEnd} 100%`);
}));
$element
.append(fadeout);
}
private beforeEventRemove(event:CalendarViewEvent) {
if (!event.event.extendedProps.entry) {
return;
}
this.removeTooltip(event);
}
private entryName(entry:TimeEntryResource) {
let name = entry.project.name;
if (entry.workPackage) {
name += ` - ${this.workPackageName(entry)}`;
}
return this.sanitizedValue(name) || '-';
}
private workPackageName(entry:TimeEntryResource) {
return `#${entry.workPackage.idFromLink}: ${entry.workPackage.name}`;
}
private tooltipContentString(entry:TimeEntryResource) {
return `
<ul class="tooltip--map">
<li class="tooltip--map--item">
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.project')}:</span>
<span class="tooltip--map--value">${this.sanitizedValue(entry.project.name)}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.work_package')}:</span>
<span class="tooltip--map--value">${entry.workPackage ? this.sanitizedValue(this.workPackageName(entry)) : this.i18n.t('js.placeholders.default')}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.activity')}:</span>
<span class="tooltip--map--value">${this.sanitizedValue(entry.activity.name)}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.duration')}:</span>
<span class="tooltip--map--value">${this.timezone.formattedDuration(entry.hours)}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.comment')}:</span>
<span class="tooltip--map--value">${entry.comment.raw || this.i18n.t('js.placeholders.default')}</span>
</li>
`;
}
private sanitizedValue(value:string) {
return this.sanitizer.sanitize(SecurityContext.HTML, value);
}
protected formatNumber(value:number):string {
return this.i18n.toNumber(value, { precision: 2 });
}
}

@ -0,0 +1,24 @@
<!-- position: relative added in order for the loading indicator to be positioned correctly -->
<div class="te-calendar--container loading-indicator--location"
[attr.data-indicator-name]="'table'"
style="position: relative">
<full-calendar #ucCalendar
[editable]="calendarEditable"
[eventLimit]="calendarEventLimit"
[locale]="calendarLocale"
[fixedWeekCount]="calendarFixedWeekCount"
[header]="calendarHeader"
[defaultView]="calendarDefaultView"
[firstDay]="calendarFirstDay"
[contentHeight]="calendarContentHeight"
[slotLabelFormat]="calendarSlotLabelFormat"
[slotEventOverlap]="calendarSlotEventOverlap"
[allDaySlot]="calendarAllDaySlot"
[allDayText]="calendarAllDayText"
[displayEventTime]="calendarDisplayEventTime"
[scrollTime]="calendarScrollTime"
[events]="calendarEvents"
[eventOverlap]="calendarEventOverlap"
[plugins]="calendarPlugins">
</full-calendar>
</div>

@ -139,7 +139,7 @@ export class WorkPackagesCalendarController implements OnInit, OnDestroy, AfterV
public addTooltip(event:CalendarViewEvent) {
jQuery(event.el).tooltip({
content: this.tooltipContentString(event.event.extendedProps.workPackage),
items: '.fc-content',
items: '.fc-event',
close: function () { jQuery(".ui-helper-hidden-accessible").remove(); },
track: true
});

@ -7,8 +7,10 @@
[required]="required"
[clearable]="!required"
[disabled]="disabled"
[typeahead]="typeahead"
[clearOnBackspace]="false"
[appendTo]="appendTo"
[hideSelected]="hideSelected"
[id]="id"
(change)="changeModel($event)"
(open)="opened()"
@ -18,4 +20,7 @@
<ng-template ng-tag-tmp let-search="searchTerm">
<b [textContent]="text.add_new_action"></b>: {{search}}
</ng-template>
<ng-template ng-option-tmp let-item="item" let-search="searchTerm">
<div [ngOptionHighlight]="search" class="ng-option-label ellipsis">{{ item.name }}</div>
</ng-template>
</ng-select>

@ -27,6 +27,11 @@
// ++
import {AfterViewInit, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
export interface CreateAutocompleterValueOption {
name:string;
$href:string|null;
}
import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper";
import {NgSelectComponent} from "@ng-select/ng-select";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@ -34,13 +39,14 @@ import {CurrentProjectService} from "core-components/projects/current-project.se
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {AddTagFn} from "@ng-select/ng-select/lib/ng-select.component";
import { Subject } from 'rxjs';
@Component({
templateUrl: './create-autocompleter.component.html',
selector: 'create-autocompleter'
})
export class CreateAutocompleterComponent implements AfterViewInit {
@Input() public availableValues:any[];
@Input() public availableValues:CreateAutocompleterValueOption[];
@Input() public appendTo:string;
@Input() public model:any;
@Input() public required:boolean = false;
@ -48,6 +54,8 @@ export class CreateAutocompleterComponent implements AfterViewInit {
@Input() public finishedLoading:boolean = false;
@Input() public id:string = '';
@Input() public classes:string = '';
@Input() public typeahead?:Subject<string>;
@Input() public hideSelected:boolean = false;
@Output() public onChange = new EventEmitter<HalResource>();
@Output() public onKeydown = new EventEmitter<JQuery.TriggeredEvent>();

@ -0,0 +1,40 @@
// -- 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 {
AfterViewInit,
Component,
} from '@angular/core';
import {CreateAutocompleterComponent} from "core-app/modules/common/autocomplete/create-autocompleter.component";
@Component({
templateUrl: './create-autocompleter.component.html',
selector: 'wp-autocompleter'
})
export class WorkPackageAutocompleterComponent extends CreateAutocompleterComponent implements AfterViewInit {
}

@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
@Injectable()
export class ColorsService {
public toHsl(value:string) {
return `hsl(${this.valueHash(value)}, 50%, 50%)`;
}
public toHsla(value:string, opacity:number) {
return `hsla(${this.valueHash(value)}, 50%, 50%, ${opacity}%)`;
}
protected valueHash(value:string) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = value.charCodeAt(i) + ((hash << 5) - hash);
}
return hash % 360;
}
}

@ -97,6 +97,8 @@ import {NgSelectModule} from "@ng-select/ng-select";
import {NgOptionHighlightModule} from "@ng-select/ng-option-highlight";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {CurrentUserService} from "core-components/user/current-user.service";
import {WorkPackageAutocompleterComponent} from "core-app/modules/common/autocomplete/wp-autocompleter.component";
import {ColorsService} from "core-app/modules/common/colors/colors.service";
export function bootstrapModule(injector:Injector) {
return () => {
@ -142,7 +144,9 @@ export function bootstrapModule(injector:Injector) {
NgSelectModule,
NgOptionHighlightModule,
DynamicModule.withComponents([VersionAutocompleterComponent,
DynamicModule.withComponents([
VersionAutocompleterComponent,
WorkPackageAutocompleterComponent,
CreateAutocompleterComponent]),
],
exports: [
@ -205,6 +209,8 @@ export function bootstrapModule(injector:Injector) {
EnterpriseBannerComponent,
DynamicModule,
WorkPackageAutocompleterComponent,
],
declarations: [
OpDatePickerComponent,
@ -274,6 +280,7 @@ export function bootstrapModule(injector:Injector) {
// Autocompleter
CreateAutocompleterComponent,
VersionAutocompleterComponent,
WorkPackageAutocompleterComponent,
HomescreenNewFeaturesBlockComponent,
BoardVideoTeaserModalComponent
@ -327,6 +334,7 @@ export function bootstrapModule(injector:Injector) {
GonService,
BackRoutingService,
HideSectionService,
ColorsService
]
})
export class OpenprojectCommonModule { }

@ -27,16 +27,19 @@
// ++
import {
SimpleResource,
SimpleResourceCollection
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
import {Apiv3NewsPaths} from "core-app/modules/common/path-helper/apiv3/news/apiv3-news-paths";
import {Apiv3TimeEntryPaths} from "core-app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entry-paths";
export class Apiv3TimeEntriesPaths extends SimpleResourceCollection {
constructor(basePath:string) {
super(basePath, 'time_entries');
}
public id(gridId:string|number) {
return new Apiv3NewsPaths(this.path, gridId);
public id(timeEntryId:string|number) {
return new Apiv3TimeEntryPaths(this.path, timeEntryId);
}
public readonly form = new SimpleResource(this.path, 'form');
}

@ -34,4 +34,7 @@ export class Apiv3TimeEntryPaths extends SimpleResource {
constructor(basePath:string, newsId:string|number) {
super(basePath, newsId);
}
// Static paths
readonly form = new SimpleResource(this.path, 'form');
}

@ -300,12 +300,14 @@ export class ResourceChangeset<T extends HalResource|{ [key:string]:unknown; } =
// Add attachments to be assigned.
// They will already be created on the server but now
// we need to claim them for the newly created work package.
payload['_links']['attachments'] = this.pristineResource
.attachments
.elements
.map((a:HalResource) => {
return { href: a.href };
});
if (this.pristineResource.attachments) {
payload['_links']['attachments'] = this.pristineResource
.attachments
.elements
.map((a:HalResource) => {
return {href: a.href};
});
}
} else {
// Otherwise, simply use the bare minimum

@ -47,6 +47,8 @@ import {UserDisplayField} from "core-app/modules/fields/display/field-types/user
import {MultipleUserFieldModule} from "core-app/modules/fields/display/field-types/multiple-user-display-field.module";
import {WorkPackageIdDisplayField} from "core-app/modules/fields/display/field-types/wp-id-display-field.module";
import {ProjectStatusDisplayField} from "core-app/modules/fields/display/field-types/project-status-display-field.module";
import {PlainFormattableDisplayField} from "core-app/modules/fields/display/field-types/plain-formattable-display-field.module";
import {LinkedWorkPackageDisplayField} from "core-app/modules/fields/display/field-types/linked-work-package-display-field.module";
export function initializeCoreDisplayFields(displayFieldService:DisplayFieldService) {
return () => {
@ -62,6 +64,7 @@ export function initializeCoreDisplayFields(displayFieldService:DisplayFieldServ
.addFieldType(TypeDisplayField, 'type', ['Type'])
.addFieldType(ResourceDisplayField, 'resource', [
'Project',
'TimeEntriesActivity',
'Version',
'Category',
'CustomOption'])
@ -73,13 +76,15 @@ export function initializeCoreDisplayFields(displayFieldService:DisplayFieldServ
.addFieldType(DateTimeDisplayField, 'datetime', ['DateTime'])
.addFieldType(BooleanDisplayField, 'boolean', ['Boolean'])
.addFieldType(ProgressDisplayField, 'progress', ['percentageDone'])
.addFieldType(WorkPackageDisplayField, 'work_package', ['WorkPackage'])
.addFieldType(LinkedWorkPackageDisplayField, 'work_package', ['WorkPackage'])
.addFieldType(IdDisplayField, 'id', ['id'])
.addFieldType(ProjectStatusDisplayField, 'project_status', ['ProjectStatus'])
.addFieldType(UserDisplayField, 'user', ['User']);
displayFieldService
.addSpecificFieldType('WorkPackage', WorkPackageIdDisplayField, 'id', ['id'])
.addSpecificFieldType('WorkPackage', WorkPackageSpentTimeDisplayField, 'spentTime', ['spentTime']);
.addSpecificFieldType('WorkPackage', WorkPackageSpentTimeDisplayField, 'spentTime', ['spentTime'])
.addSpecificFieldType('TimeEntry', PlainFormattableDisplayField, 'comment', ['comment'])
.addSpecificFieldType('TimeEntry', WorkPackageDisplayField, 'work_package', ['workPackage']);
};
}

@ -0,0 +1,69 @@
// -- 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 {StateService} from '@uirouter/core';
import {KeepTabService} from 'core-components/wp-single-view-tabs/keep-tab/keep-tab.service';
import {UiStateLinkBuilder} from "core-components/wp-fast-table/builders/ui-state-link-builder";
import {WorkPackageDisplayField} from "core-app/modules/fields/display/field-types/work-package-display-field.module";
export class LinkedWorkPackageDisplayField extends WorkPackageDisplayField {
public text = {
linkTitle: this.I18n.t('js.work_packages.message_successful_show_in_fullscreen'),
none: this.I18n.t('js.filter.noneElement')
};
private $state:StateService = this.$injector.get(StateService);
private keepTab:KeepTabService = this.$injector.get(KeepTabService);
private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.$state, this.keepTab);
public render(element:HTMLElement, displayText:string):void {
if (this.isEmpty()) {
element.innerText = this.placeholder;
return;
}
let link = this.uiStateBuilder.linkToShow(
this.wpId,
this.text.linkTitle,
this.valueString
);
element.innerHTML = '';
element.appendChild(link);
}
public get writable():boolean {
return false;
}
public get valueString() {
return '#' + this.wpId;
}
}

@ -0,0 +1,40 @@
// -- 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 {DisplayField} from "core-app/modules/fields/display/display-field.module";
export class PlainFormattableDisplayField extends DisplayField {
public get value() {
if (!this.schema) {
return null;
}
const element = this.resource[this.name];
return element && element.raw || '';
}
}

@ -27,38 +27,13 @@
// ++
import {DisplayField} from "core-app/modules/fields/display/display-field.module";
import {StateService} from '@uirouter/core';
import {KeepTabService} from 'core-components/wp-single-view-tabs/keep-tab/keep-tab.service';
import {UiStateLinkBuilder} from "core-components/wp-fast-table/builders/ui-state-link-builder";
export class WorkPackageDisplayField extends DisplayField {
public text = {
linkTitle: this.I18n.t('js.work_packages.message_successful_show_in_fullscreen'),
none: this.I18n.t('js.filter.noneElement')
};
private $state:StateService = this.$injector.get(StateService);
private keepTab:KeepTabService = this.$injector.get(KeepTabService);
private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.$state, this.keepTab);
public render(element:HTMLElement, displayText:string):void {
if (this.isEmpty()) {
element.innerText = this.placeholder;
return;
}
let link = this.uiStateBuilder.linkToShow(
this.wpId,
this.text.linkTitle,
this.valueString
);
element.innerHTML = '';
element.appendChild(link);
}
public get value() {
return this.resource[this.name];
}
@ -84,12 +59,9 @@ export class WorkPackageDisplayField extends DisplayField {
return this.value.href.match(/(\d+)$/)[0];
}
public get writable():boolean {
return false;
}
public get valueString() {
return '#' + this.wpId;
// cannot display the type name easily here as it may not be loaded
return `#${ this.wpId } ${ this.title }`;
}
public isEmpty():boolean {
@ -99,5 +71,4 @@ export class WorkPackageDisplayField extends DisplayField {
public get unknownAttribute():boolean {
return false;
}
}

@ -40,8 +40,10 @@ import {FormattableEditFieldComponent} from "core-app/modules/fields/edit/field-
import {WorkPackageCommentFieldComponent} from "core-components/work-packages/work-package-comment/wp-comment-field.component";
import {SelectAutocompleterRegisterService} from "core-app/modules/fields/edit/field-types/select-autocompleter-register.service";
import {VersionAutocompleterComponent} from "core-app/modules/common/autocomplete/version-autocompleter.component";
import {ProjectStatusDisplayField} from "core-app/modules/fields/display/field-types/project-status-display-field.module";
import {ProjectStatusEditFieldComponent} from "core-app/modules/fields/edit/field-types/project-status-edit-field.component";
import {PlainFormattableEditFieldComponent} from "core-app/modules/fields/edit/field-types/plain-formattable-edit-field.component";
import {WorkPackageAutocompleterComponent} from "core-app/modules/common/autocomplete/wp-autocompleter.component";
import {TimeEntryWorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/te-work-package-edit-field.component";
export function initializeCoreEditFields(editFieldService:EditFieldService, selectAutocompleterRegisterService:SelectAutocompleterRegisterService) {
@ -56,6 +58,7 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele
'Type',
'User',
'Version',
'TimeEntriesActivity',
'Category',
'CustomOption',
'Project'])
@ -71,6 +74,11 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele
.addFieldType(ProjectStatusEditFieldComponent, 'project_status', ['ProjectStatus'])
.addFieldType(WorkPackageCommentFieldComponent, '_comment', ['comment']);
editFieldService
.addSpecificFieldType('TimeEntry', PlainFormattableEditFieldComponent, 'comment', ['comment'])
.addSpecificFieldType('TimeEntry', TimeEntryWorkPackageEditFieldComponent, 'workPackage', ['WorkPackage']);
selectAutocompleterRegisterService.register(VersionAutocompleterComponent, 'Version');
selectAutocompleterRegisterService.register(WorkPackageAutocompleterComponent, 'WorkPackage');
};
}

@ -82,7 +82,7 @@ export class EditFormComponent extends EditForm<HalResource> implements OnInit,
}
// Show confirmation message when transitioning to a new state
// that's not withing the edit mode.
// that's not within the edit mode.
if (!this.editFormRouting || this.editFormRouting.blockedTransition(transition)) {
if (requiresConfirmation && !window.confirm(confirmText)) {
return false;

@ -57,7 +57,7 @@ export class EditFormPortalComponent implements OnInit, OnDestroy, AfterViewInit
this.change = this.injector.get<ResourceChangeset>(OpEditingPortalChangesetToken);
}
this.componentClass = this.editField.getClassFor(this.handler.fieldName, this.schema.type);
this.componentClass = this.editField.getSpecificClassFor(this.change.pristineResource._type, this.handler.fieldName, this.schema.type);
this.fieldInjector = createLocalInjector(this.injector, this.change, this.handler, this.schema);
}

@ -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 {Component} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
@Component({
templateUrl: './text-edit-field.component.html'
})
export class PlainFormattableEditFieldComponent extends EditFieldComponent {
// only exists because the template is reused and the property is required there.
public shouldFocus = false;
public get value() {
if (!this.schema) {
return '';
}
const element = this.resource[this.name];
return element && element.raw || '';
}
public set value(newValue:string) {
this.resource[this.name] = { raw: newValue };
}
}

@ -26,16 +26,17 @@
// See docs/COPYRIGHT.rdoc for more details.
// ++
import {Component, OnInit, ViewChild} from "@angular/core";
import {Component, OnInit} from "@angular/core";
import {HalResourceSortingService} from "core-app/modules/hal/services/hal-resource-sorting.service";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {EditFieldComponent} from "../edit-field.component";
import {AngularTrackingHelpers} from "core-components/angular/tracking-functions";
import {untilComponentDestroyed} from "ng2-rx-componentdestroyed";
import {NgSelectComponent} from "@ng-select/ng-select";
import {CreateAutocompleterComponent} from "core-app/modules/common/autocomplete/create-autocompleter.component";
import {SelectAutocompleterRegisterService} from "app/modules/fields/edit/field-types/select-autocompleter-register.service";
import { from } from 'rxjs';
import { tap, map, skip } from 'rxjs/operators';
import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service";
export interface ValueOption {
name:string;
@ -47,9 +48,12 @@ export interface ValueOption {
})
export class SelectEditFieldComponent extends EditFieldComponent implements OnInit {
public selectAutocompleterRegister = this.injector.get(SelectAutocompleterRegisterService);
public halNotification = this.injector.get(HalResourceNotificationService);
public availableOptions:any[];
public valueOptions:ValueOption[];
protected valuesLoaded = false;
public text:{ requiredPlaceholder:string, placeholder:string };
public appendTo:any = null;
@ -74,18 +78,11 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn
requiredPlaceholder: this.I18n.t('js.placeholders.selection'),
placeholder: this.I18n.t('js.placeholders.default')
};
}
const loadingPromise = this.loadValues();
this.handler
.$onUserActivate
.pipe(
untilComponentDestroyed(this)
)
.subscribe(() => {
loadingPromise.then(() => {
this._autocompleterComponent.openDirectly = true;
});
});
protected initialValueLoading() {
this.valuesLoaded = false;
return this.loadValues().toPromise();
}
public autocompleterComponent() {
@ -96,6 +93,21 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn
public ngOnInit() {
super.ngOnInit();
this.appendTo = this.overflowingSelector;
let loadingPromise = this.change.getForm().then(() => {
return this.initialValueLoading();
});
this.handler
.$onUserActivate
.pipe(
untilComponentDestroyed(this),
)
.subscribe(() => {
loadingPromise.then(() => {
this._autocompleterComponent.openDirectly = true;
});
});
}
public get selectedOption() {
@ -118,23 +130,45 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn
private setValues(availableValues:HalResource[]) {
this.availableOptions = this.halSorting.sort(availableValues);
this.addEmptyOption();
this.valueOptions = this.availableOptions.map(el => {
return {name: el.name, $href: el.$href};
});
this.valueOptions = this.availableOptions.map(el => this.mapAllowedValue(el));
}
private loadValues() {
protected loadValues(query?:string) {
let allowedValues = this.schema.allowedValues;
if (Array.isArray(allowedValues)) {
this.setValues(allowedValues);
} else if (allowedValues) {
return allowedValues.$load(false).then((values:CollectionResource) => {
this.setValues(values.elements);
});
this.valuesLoaded = true;
} else if (allowedValues && !this.valuesLoaded) {
return this.loadValuesFromBackend(query);
} else {
this.setValues([]);
}
return Promise.resolve();
return from(Promise.resolve(this.valueOptions));
}
protected loadValuesFromBackend(query?:string) {
return from(
this.schema.allowedValues.$link.$fetch(this.allowedValuesFilter(query)) as Promise<CollectionResource>
).pipe(
tap(collection => {
// if it is an unpaginated collection or if we get all possible entries when fetching with a blank
// query, we do not need to load the values again;
if (collection.count === undefined || collection.total === undefined || (!query && collection.total === collection.count)) {
this.valuesLoaded = true;
}
}),
map(collection => {
if (collection.count === undefined || collection.total === undefined || (!query && collection.total === collection.count) || !this.value) {
return collection.elements;
} else {
return collection.elements.concat([this.value]);
}
}),
tap(elements => this.setValues(elements)),
map(() => this.valueOptions)
);
}
private addValue(val:HalResource) {
@ -183,7 +217,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn
private addEmptyOption() {
// Empty options are not available for required fields
if (this.schema.required) {
if (this.isRequired()) {
return;
}
@ -198,6 +232,20 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn
}
}
protected isRequired() {
return this.schema.required;
}
protected mapAllowedValue(value:HalResource):ValueOption {
return {name: value.name, $href: value.$href};
}
// Subclasses shall be able to override the filters with which the
// allowed values are reduced in the backend.
protected allowedValuesFilter(query?:string) {
return {};
}
private getEmptyOption():ValueOption|undefined {
return _.find(this.availableOptions, el => el.name === this.text.placeholder);
}

@ -0,0 +1,44 @@
// -- 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 {Component} from "@angular/core";
import {WorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/work-package-edit-field.component";
@Component({
templateUrl: './work-package-edit-field.component.html'
})
export class TimeEntryWorkPackageEditFieldComponent extends WorkPackageEditFieldComponent {
// Although the schema states the work packages to not be required,
// as time entries can also be assigned to a project, we want to only assign
// time entries to work packages and thus require a value.
// The back end will have to be changed in due time but not as long as there is still a rails based
// time entry view in the application.
protected isRequired() {
return true;
}
}

@ -0,0 +1,10 @@
<input type="text"
class="inline-edit--field"
[focus]="shouldFocus"
[attr.aria-required]="required"
[attr.required]="required"
[disabled]="inFlight"
[(ngModel)]="value"
(keydown)="handler.handleUserKeydown($event)"
(focusout)="handler.onFocusOut()"
[id]="handler.htmlId" />

@ -30,18 +30,7 @@ import {Component} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
@Component({
template: `
<input type="text"
class="inline-edit--field"
[focus]="shouldFocus"
[attr.aria-required]="required"
[attr.required]="required"
[disabled]="inFlight"
[(ngModel)]="value"
(keydown)="handler.handleUserKeydown($event)"
(focusout)="handler.onFocusOut()"
[id]="handler.htmlId" />
`
templateUrl: './text-edit-field.component.html'
})
export class TextEditFieldComponent extends EditFieldComponent {
// ToDo: Work package specific

@ -0,0 +1,13 @@
<ndc-dynamic [ndcDynamicComponent]="autocompleterComponent()"
[ndcDynamicInputs]="{ availableValues: requests.output$ | async,
appendTo: appendTo,
model: selectedOption ? selectedOption : '',
required: required,
disabled: inFlight,
typeahead: requests.input$,
id: handler.htmlId,
finishedLoading: true,
hideSelected: true,
classes: 'inline-edit--field ' + handler.fieldName }"
[ndcDynamicOutputs]="referenceOutputs">
</ndc-dynamic>

@ -27,16 +27,62 @@
// ++
import {Component} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {SelectEditFieldComponent, ValueOption} from './select-edit-field.component';
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {DebouncedRequestSwitchmap, errorNotificationHandler} from "core-app/helpers/rxjs/debounced-input-switchmap";
import { take } from 'rxjs/operators';
@Component({
template: `
<input type="text"
class="inline-edit--field"
disabled
[ngModel]="value"
[id]="handler.htmlId" />
`
templateUrl: './work-package-edit-field.component.html'
})
export class WorkPackageEditFieldComponent extends EditFieldComponent {
export class WorkPackageEditFieldComponent extends SelectEditFieldComponent {
/** Keep a switchmap for search term and loading state */
public requests = new DebouncedRequestSwitchmap<string, ValueOption>(
(searchTerm:string) => this.loadValues(searchTerm),
errorNotificationHandler(this.halNotification)
);
protected initialValueLoading() {
this.valuesLoaded = false;
// Using this hack with the empty value to have the values loaded initially
// while avoiding loading it multiple times.
return new Promise<ValueOption[]>((resolve) => {
this.requests.output$.pipe(take(1)).subscribe(options => {
resolve(options);
});
this.requests.input$.next('');
});
}
protected allowedValuesFilter(query?:string):{} {
let filterParams = super.allowedValuesFilter(query);
if (query) {
let filters:ApiV3FilterBuilder = new ApiV3FilterBuilder();
filters.add('subjectOrId', '**', [query]);
filterParams = { filters: filters.toJson() };
}
return filterParams;
}
protected mapAllowedValue(value:WorkPackageResource|ValueOption):ValueOption {
if ((value as WorkPackageResource).id) {
let prefix = (value as WorkPackageResource).type ? `${(value as WorkPackageResource).type.name} ` : '';
let suffix = (value as WorkPackageResource).subject || value.name;
return {
name: `${prefix}#${ (value as WorkPackageResource).id } ${suffix}`,
$href: value.$href
};
} else {
return value;
}
}
}

@ -116,8 +116,8 @@ export class EditableAttributeFieldComponent implements OnInit, OnDestroy {
.pipe(
untilComponentDestroyed(this)
)
.subscribe(workPackage => {
this.resource = workPackage;
.subscribe(resource => {
this.resource = resource;
this.render();
});
}

@ -54,6 +54,8 @@ import {WorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-
import {EditableAttributeFieldComponent} from "core-app/modules/fields/edit/field/editable-attribute-field.component";
import {ProjectStatusEditFieldComponent} from "core-app/modules/fields/edit/field-types/project-status-edit-field.component";
import {PortalCleanupService} from "core-app/modules/fields/display/display-portal/portal-cleanup.service";
import {PlainFormattableEditFieldComponent} from "core-app/modules/fields/edit/field-types/plain-formattable-edit-field.component";
import {TimeEntryWorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/te-work-package-edit-field.component";
@NgModule({
imports: [
@ -87,11 +89,13 @@ import {PortalCleanupService} from "core-app/modules/fields/display/display-port
FloatEditFieldComponent,
IntegerEditFieldComponent,
FormattableEditFieldComponent,
PlainFormattableEditFieldComponent,
MultiSelectEditFieldComponent,
SelectEditFieldComponent,
TextEditFieldComponent,
EditFieldControlsComponent,
WorkPackageEditFieldComponent,
TimeEntryWorkPackageEditFieldComponent,
EditFormComponent,
EditableAttributeFieldComponent,
ProjectStatusEditFieldComponent,
@ -105,10 +109,12 @@ import {PortalCleanupService} from "core-app/modules/fields/display/display-port
FloatEditFieldComponent,
IntegerEditFieldComponent,
FormattableEditFieldComponent,
PlainFormattableEditFieldComponent,
MultiSelectEditFieldComponent,
SelectEditFieldComponent,
TextEditFieldComponent,
WorkPackageEditFieldComponent,
TimeEntryWorkPackageEditFieldComponent,
EditableAttributeFieldComponent,
ProjectStatusEditFieldComponent,
]

@ -116,8 +116,6 @@ export class GlobalSearchInputComponent implements OnInit, OnDestroy {
this.ngSelectComponent.searchTerm = this.currentValue = this.globalSearchService.searchTerm;
this.expanded = (this.ngSelectComponent.searchTerm.length > 0);
jQuery('#top-menu').toggleClass('-global-search-expanded', this.expanded);
}
ngOnDestroy() {

@ -35,7 +35,7 @@ import {FormsModule} from '@angular/forms';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {OpenprojectWorkPackagesModule} from "core-app/modules/work_packages/openproject-work-packages.module";
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/current-user/time-entries-current-user.component";
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 {GridComponent} from "core-app/modules/grids/grid/grid.component";
import {AddGridWidgetModal} from "core-app/modules/grids/widgets/add/add.modal";
@ -59,7 +59,7 @@ import {WidgetWpOverviewComponent} from "core-app/modules/grids/widgets/wp-overv
import {WidgetCustomTextComponent} from "core-app/modules/grids/widgets/custom-text/custom-text.component";
import {OpenprojectFieldsModule} from "core-app/modules/fields/openproject-fields.module";
import {WidgetProjectDetailsComponent} from "core-app/modules/grids/widgets/project-details/project-details.component";
import {WidgetTimeEntriesProjectComponent} from "core-app/modules/grids/widgets/time-entries-current-user/project/time-entries-project.component";
import {WidgetTimeEntriesProjectComponent} from "core-app/modules/grids/widgets/time-entries/project/time-entries-project.component";
import {WidgetSubprojectsComponent} from "core-app/modules/grids/widgets/subprojects/subprojects.component";
import {OpenprojectAttachmentsModule} from "core-app/modules/attachments/openproject-attachments.module";
import {WidgetMembersComponent} from "core-app/modules/grids/widgets/members/members.component";

@ -5,7 +5,6 @@ import {OpModalLocalsToken} from "app/components/op-modals/op-modal.service";
import {OpModalLocalsMap} from "app/components/op-modals/op-modal.types";
import {GridWidgetsService} from "app/modules/grids/widgets/widgets.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {BannersService} from "core-app/modules/common/enterprise/banners.service";
@Component({

@ -1,13 +0,0 @@
import {Component, OnInit} from "@angular/core";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {WidgetTimeEntriesListComponent} from "core-app/modules/grids/widgets/time-entries-current-user/list/time-entries-list.component";
@Component({
templateUrl: '../list/time-entries-list.component.html',
})
export class WidgetTimeEntriesCurrentUserComponent extends WidgetTimeEntriesListComponent implements OnInit {
protected dmFilters():Array<[string, FilterOperator, [string]]> {
return [['spentOn', '>t-', ['7']] as [string, FilterOperator, [string]],
['user_id', '=', ['me']] as [string, FilterOperator, [string]]];
}
}

@ -0,0 +1,18 @@
<widget-header
[name]="widgetName"
(onRenamed)="renameWidget($event)">
<widget-menu
[resource]="resource">
</widget-menu>
</widget-header>
<te-calendar
(entries)="updateEntries($event)"
></te-calendar>
<ng-container>
<div class="total-hours">
<p>Total: <span [textContent]="total"></span></p>
</div>
</ng-container>

@ -0,0 +1,45 @@
import {Component, Injector, ChangeDetectionStrategy, ChangeDetectorRef} from "@angular/core";
import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource';
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {TimezoneService} from "core-components/datetime/timezone.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
@Component({
templateUrl: './time-entries-current-user.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WidgetTimeEntriesCurrentUserComponent extends AbstractWidgetComponent {
public entries:TimeEntryResource[] = [];
constructor(protected readonly injector:Injector,
readonly timezone:TimezoneService,
readonly i18n:I18nService,
readonly pathHelper:PathHelperService,
protected readonly cdr:ChangeDetectorRef) {
super(i18n, injector);
}
public updateEntries(entries:CollectionResource<TimeEntryResource>) {
this.entries = entries.elements;
this.cdr.detectChanges();
}
public get total() {
let duration = this.entries.reduce((current, entry) => {
return current + this.timezone.toHours(entry.hours);
}, 0);
if (duration > 0) {
return this.i18n.t('js.units.hour', { count: this.formatNumber(duration) });
} else {
return this.i18n.t('js.placeholders.default');
}
}
protected formatNumber(value:number):string {
return this.i18n.toNumber(value, { precision: 2 });
}
}

@ -37,7 +37,7 @@ export abstract class WidgetTimeEntriesListComponent extends AbstractWidgetCompo
}
ngOnInit() {
this.timeEntryDm.list({ filters: this.dmFilters() })
this.timeEntryDm.list({ filters: this.dmFilters(), pageSize: 500 })
.then((collection) => {
this.buildEntries(collection.elements);
this.entriesLoaded = true;
@ -149,7 +149,7 @@ export abstract class WidgetTimeEntriesListComponent extends AbstractWidgetCompo
//entries
}
private formatNumber(value:number):string {
protected formatNumber(value:number):string {
return this.i18n.toNumber(value, { precision: 2 });
}

@ -1,6 +1,6 @@
import {Component, OnInit, Injector, ChangeDetectorRef} from "@angular/core";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {WidgetTimeEntriesListComponent} from "core-app/modules/grids/widgets/time-entries-current-user/list/time-entries-list.component";
import {WidgetTimeEntriesListComponent} from "core-app/modules/grids/widgets/time-entries/list/time-entries-list.component";
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service";
import {TimezoneService} from "core-components/datetime/timezone.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";

@ -20,7 +20,6 @@ export class WidgetWpGraphComponent extends AbstractWidgetComponent implements O
constructor(protected i18n:I18nService,
protected injector:Injector,
protected cdr:ChangeDetectorRef,
protected urlParamsHelper:UrlParamsHelperService,
protected readonly graphConfiguration:WpGraphConfigurationService) {
super(i18n, injector);
}

@ -29,9 +29,22 @@
import {Injectable} from '@angular/core';
import {AbstractDmService} from "core-app/modules/hal/dm-services/abstract-dm.service";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {FormResource} from "core-app/modules/hal/resources/form-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {PayloadDmService} from "core-app/modules/hal/dm-services/payload-dm.service";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
@Injectable()
export class TimeEntryDmService extends AbstractDmService<TimeEntryResource> {
constructor(protected halResourceService:HalResourceService,
protected pathHelper:PathHelperService,
protected payloadDm:PayloadDmService) {
super(halResourceService, pathHelper);
}
protected listUrl() {
return this.pathHelper.api.v3.time_entries.toString();
}
@ -39,4 +52,37 @@ export class TimeEntryDmService extends AbstractDmService<TimeEntryResource> {
protected oneUrl(id:number|string) {
return this.pathHelper.api.v3.time_entries.id(id).toString();
}
public update(resource:TimeEntryResource, schema:SchemaResource|null = null) {
let payload = this.extractPayload(resource, schema);
return this.halResourceService.patch<TimeEntryResource>(resource.updateImmediately.$link.href, payload).toPromise();
}
public updateForm(resource:TimeEntryResource, schema:SchemaResource|null = null) {
let payload = this.extractPayload(resource, schema);
return this.halResourceService.post<FormResource>(this.pathHelper.api.v3.time_entries.id(resource.idFromLink).form.toString(),
payload).toPromise();
}
public createForm(payload:{}) {
return this.halResourceService.post<FormResource>(this.pathHelper.api.v3.time_entries.form.toString(), payload).toPromise();
}
public create(payload:{}):Promise<TimeEntryResource> {
return this.halResourceService
.post<TimeEntryResource>(this.pathHelper.api.v3.time_entries.path, payload)
.toPromise();
}
public extractPayload(resource:TimeEntryResource|null = null, schema:SchemaResource|null = null) {
if (resource && schema) {
return this.payloadDm.extract(resource, schema);
} else if (!(resource instanceof HalResource)) {
return resource;
} else {
return {};
}
}
}

@ -31,8 +31,6 @@ import {HalLinkInterface} from 'core-app/modules/hal/hal-link/hal-link';
import {Injector} from '@angular/core';
import {States} from 'core-components/states.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
export interface HalResourceClass<T extends HalResource = HalResource> {
new(injector:Injector,

@ -27,6 +27,49 @@
//++
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
export class TimeEntryResource extends HalResource {
// TODO: extract the whole overridden Schema stuff into halresource or use the schemaCacheService
// to place it there
readonly schemaCacheService:SchemaCacheService = this.injector.get(SchemaCacheService);
public overriddenSchema:SchemaResource|undefined = undefined;
/**
* Get the current schema, assuming it is either:
* 1. Overridden by the current loaded form
* 2. Available as a schema state
*
* If it is neither, an exception is raised.
*/
public get schema():SchemaResource {
if (this.hasOverriddenSchema) {
return this.overriddenSchema!;
}
const state = this.schemaCacheService.state(this as any);
if (!state.hasValue()) {
throw `Accessing schema of ${this.id} without it being loaded.`;
}
return state.value!;
}
public get hasOverriddenSchema():boolean {
return this.overriddenSchema != null;
}
public get state() {
return this.states.timeEntries.get(this.id!) as any;
}
/**
* Exclude the schema _link from the linkable Resources.
*/
public $linkableKeys():string[] {
return _.without(super.$linkableKeys(), 'schema');
}
}

@ -0,0 +1,37 @@
<div class="op-modal--portal ngdialog-theme-openproject">
<div class="op-modal--modal-container confirm-dialog--modal loading-indicator--location"
data-indicator-name="modal"
tabindex="0">
<div class="op-modal--modal-header">
<a class="op-modal--modal-close-button">
<i
class="icon-close"
(click)="closeMe($event)"
[attr.title]="text.close">
</i>
</a>
<h3 class="icon-context icon-attention" [textContent]="text.title"></h3>
</div>
<div class="ngdialog-body op-modal--modal-body">
<te-form #editForm
[entry]="entry"
(modifiedEntry)="setModifiedEntry($event)">
</te-form>
</div>
<div class="op-modal--modal-footer">
<button class="button -highlight"
(click)="createEntry()"
[textContent]="text.create"
[attr.title]="text.create">
</button>
<button class="button"
(click)="closeMe($event)"
[textContent]="text.cancel"
[attr.title]="text.cancel">
</button>
</div>
</div>
</div>

@ -0,0 +1,57 @@
import {Component, ElementRef, Inject, ChangeDetectorRef, ViewChild, ChangeDetectionStrategy} from "@angular/core";
import {OpModalComponent} from "app/components/op-modals/op-modal.component";
import {OpModalLocalsToken} from "app/components/op-modals/op-modal.service";
import {OpModalLocalsMap} from "app/components/op-modals/op-modal.types";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {TimeEntryFormComponent} from "core-app/modules/time_entries/form/form.component";
@Component({
templateUrl: './create.modal.html',
styleUrls: ['../edit/edit.modal.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
HalResourceEditingService
]
})
export class TimeEntryCreateModal extends OpModalComponent {
@ViewChild('editForm', { static: true }) editForm:TimeEntryFormComponent;
text = {
title: this.i18n.t('js.time_entry.create'),
create: this.i18n.t('js.label_create'),
close: this.i18n.t('js.button_close'),
cancel: this.i18n.t('js.button_cancel')
};
public closeOnEscape = false;
public closeOnOutsideClick = false;
public createdEntry:TimeEntryResource;
constructor(readonly elementRef:ElementRef,
@Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly i18n:I18nService,
readonly halEditing:HalResourceEditingService) {
super(locals, cdRef, elementRef);
}
public get entry() {
return this.locals.entry;
}
public createEntry() {
this.editForm.save()
.then(() => {
this.service.close();
});
}
public setModifiedEntry($event:{savedResource:HalResource, isInital:boolean}) {
this.createdEntry = $event.savedResource as TimeEntryResource;
}
}

@ -0,0 +1,80 @@
import {Injectable, Injector} from "@angular/core";
import {OpModalService} from "app/components/op-modals/op-modal.service";
import {HalResourceService} from "app/modules/hal/services/hal-resource.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource';
import { take } from 'rxjs/operators';
import {FormResource} from "core-app/modules/hal/resources/form-resource";
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service";
import {ResourceChangeset} from "core-app/modules/fields/changeset/resource-changeset";
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import { Moment } from 'moment';
import {TimeEntryCreateModal} from "core-app/modules/time_entries/create/create.modal";
@Injectable()
export class TimeEntryCreateService {
constructor(readonly opModalService:OpModalService,
readonly injector:Injector,
readonly halResource:HalResourceService,
readonly timeEntryDm:TimeEntryDmService,
protected halEditing:HalResourceEditingService,
readonly i18n:I18nService) {
}
public create(date:Moment) {
return new Promise<{entry:TimeEntryResource, action:'create'}>((resolve, reject) => {
this
.createNewTimeEntry(date)
.then(changeset => {
const modal = this.opModalService.show(TimeEntryCreateModal, this.injector, { entry: changeset.pristineResource });
modal
.closingEvent
.pipe(take(1))
.subscribe(() => {
if (modal.createdEntry) {
resolve({entry: modal.createdEntry, action: 'create'});
} else {
reject();
}
});
});
});
}
public createNewTimeEntry(date:Moment) {
return this.timeEntryDm.createForm({ spentOn: date.format('YYYY-MM-DD') }).then(form => {
return this.fromCreateForm(form);
});
}
public fromCreateForm(form:FormResource):ResourceChangeset {
let entry = this.initializeNewResource(form);
return this.halEditing.edit<TimeEntryResource, ResourceChangeset<TimeEntryResource>>(entry, form);
}
private initializeNewResource(form:FormResource) {
let entry = this.halResource.createHalResourceOfType<TimeEntryResource>('TimeEntry', form.payload.$plain());
entry.$links['schema'] = form.schema;
entry.overriddenSchema = form.schema;
entry['_type'] = 'TimeEntry';
entry['id'] = 'new';
entry['hours'] = 'PT1H';
// Set update link to form
entry['update'] = entry.$links['update'] = form.$links.self;
// Use POST /work_packages for saving link
entry['updateImmediately'] = entry.$links['updateImmediately'] = (payload:{}) => {
return this.timeEntryDm.create(payload);
};
entry.state.putValue(entry);
return entry;
}
}

@ -0,0 +1,40 @@
<div class="op-modal--portal ngdialog-theme-openproject">
<div class="op-modal--modal-container confirm-dialog--modal loading-indicator--location"
data-indicator-name="modal"
tabindex="0">
<div class="op-modal--modal-header">
<a class="op-modal--modal-close-button">
<i
class="icon-close"
(click)="closeMe($event)"
[attr.title]="text.close">
</i>
</a>
<h3 class="icon-context icon-attention" [textContent]="text.title"></h3>
</div>
<div class="ngdialog-body op-modal--modal-body">
<te-form [entry]="entry"
(modifiedEntry)="setModifiedEntry($event)">
</te-form>
</div>
<div class="op-modal--modal-footer">
<button class="button -highlight"
(click)="closeMe($event)"
[textContent]="text.close"
[attr.title]="text.close">
</button>
<button class="button"
*ngIf="deleteAllowed"
(click)="destroy()"
[attr.title]="text.delete">
<op-icon icon-classes="button--icon icon-delete"></op-icon>
<span class="button--text"
[textContent]="text.delete"
aria-hidden="true"></span>
</button>
</div>
</div>
</div>

@ -0,0 +1,9 @@
.op-modal--modal-container
max-width: 90vw
width: 800px
.op-modal--modal-footer
margin-top: 2em
.button
margin-bottom: 0

@ -0,0 +1,56 @@
import {Component, ElementRef, Inject, ChangeDetectorRef, ChangeDetectionStrategy} from "@angular/core";
import {OpModalComponent} from "app/components/op-modals/op-modal.component";
import {OpModalLocalsToken} from "app/components/op-modals/op-modal.service";
import {OpModalLocalsMap} from "app/components/op-modals/op-modal.types";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
@Component({
templateUrl: './edit.modal.html',
styleUrls: ['./edit.modal.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
HalResourceEditingService
]
})
export class TimeEntryEditModal extends OpModalComponent {
text = {
title: this.i18n.t('js.time_entry.edit'),
close: this.i18n.t('js.button_close'),
delete: this.i18n.t('js.button_delete')
};
public closeOnEscape = false;
public closeOnOutsideClick = false;
public modifiedEntry:TimeEntryResource;
public destroyedEntry:TimeEntryResource;
constructor(readonly elementRef:ElementRef,
@Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly i18n:I18nService) {
super(locals, cdRef, elementRef);
}
public get entry() {
return this.locals.entry;
}
public setModifiedEntry($event:{savedResource:HalResource, isInital:boolean}) {
this.modifiedEntry = $event.savedResource as TimeEntryResource;
}
public get deleteAllowed() {
return !!this.entry.delete;
}
public destroy() {
this.destroyedEntry = this.entry;
this.service.close();
}
}

@ -0,0 +1,40 @@
import {Injectable, Injector} from "@angular/core";
import {OpModalService} from "app/components/op-modals/op-modal.service";
import {HalResourceService} from "app/modules/hal/services/hal-resource.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource';
import { TimeEntryEditModal } from './edit.modal';
import { take } from 'rxjs/operators';
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
@Injectable()
export class TimeEntryEditService {
constructor(readonly opModalService:OpModalService,
readonly injector:Injector,
readonly halResource:HalResourceService,
protected halEditing:HalResourceEditingService,
readonly i18n:I18nService) {
}
public edit(entry:TimeEntryResource) {
return new Promise<{entry:TimeEntryResource, action:'update'|'destroy'}>((resolve, reject) => {
const modal = this.opModalService.show(TimeEntryEditModal, this.injector, { entry: entry });
modal
.closingEvent
.pipe(take(1))
.subscribe(() => {
if (modal.destroyedEntry) {
modal.destroyedEntry.delete().then(() => {
resolve({entry: modal.destroyedEntry, action: 'destroy'});
});
} else if (modal.modifiedEntry) {
resolve({ entry: modal.modifiedEntry, action: 'update' });
} else {
reject();
}
});
});
}
}

@ -0,0 +1,56 @@
<edit-form
#editForm
[resource]="entry"
[inEditMode]="inEditMode"
(onSaved)="signalModifiedEntry($event)">
<div class="attributes-map">
<div class="attributes-map--key" [textContent]="text.attributes.spentOn"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'spentOn'">
</editable-attribute-field>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.hours"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'hours'">
</editable-attribute-field>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.workPackage"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'workPackage'">
</editable-attribute-field>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.activity"></div>
<div class="attributes-map--value">
<editable-attribute-field *ngIf="workPackageSelected"
[resource]="entry"
[fieldName]="'activity'">
</editable-attribute-field>
<i *ngIf="!workPackageSelected"
[textContent]="text.wpRequired">
</i>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.comment"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'comment'">
</editable-attribute-field>
</div>
<ng-container *ngFor="let cf of customFields">
<div class="attributes-map--key" [textContent]="cf.label"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="cf.key">
</editable-attribute-field>
</div>
</ng-container>
</div>
</edit-form>

@ -0,0 +1,82 @@
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import { ViewEncapsulation, Component, Input, EventEmitter, Output, OnInit, OnDestroy, ViewChild, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { untilComponentDestroyed } from 'ng2-rx-componentdestroyed';
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import { EditFormComponent } from 'core-app/modules/fields/edit/edit-form/edit-form.component';
@Component({
templateUrl: './form.component.html',
selector: 'te-form',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TimeEntryFormComponent implements OnInit, OnDestroy {
@Input() entry:TimeEntryResource;
@Output() modifiedEntry = new EventEmitter<{savedResource:TimeEntryResource, isInital:boolean}>();
@ViewChild('editForm', { static: true }) editForm:EditFormComponent;
text = {
attributes: {
comment: this.i18n.t('js.time_entry.comment'),
hours: this.i18n.t('js.time_entry.hours'),
activity: this.i18n.t('js.time_entry.activity'),
workPackage: this.i18n.t('js.time_entry.work_package'),
spentOn: this.i18n.t('js.time_entry.spent_on'),
},
wpRequired: this.i18n.t('js.time_entry.work_package_required')
};
public workPackageSelected:boolean = false;
public customFields:{key:string, label:string}[] = [];
constructor(readonly halEditing:HalResourceEditingService,
readonly cdRef:ChangeDetectorRef,
readonly i18n:I18nService) {
}
ngOnInit() {
this.halEditing
.temporaryEditResource(this.entry)
.values$()
.pipe(
untilComponentDestroyed(this)
)
.subscribe(changeset => {
if (changeset && changeset.workPackage) {
this.workPackageSelected = true;
this.cdRef.markForCheck();
}
});
this.setCustomFields(this.entry.schema);
this.cdRef.detectChanges();
}
ngOnDestroy() {
// nothing to do
}
public signalModifiedEntry($event:{savedResource:HalResource, isInital:boolean}) {
this.modifiedEntry.emit($event as {savedResource:TimeEntryResource, isInital:boolean});
}
public save() {
return this.editForm.save();
}
public get inEditMode() {
return this.entry.isNew;
}
private setCustomFields(schema:SchemaResource) {
Object.entries(schema).forEach(([key, keySchema]) => {
if (key.match(/customField\d+/)) {
this.customFields.push({key: key, label: keySchema.name });
}
});
}
}

@ -0,0 +1,60 @@
// -- 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-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 docs/COPYRIGHT.rdoc for more details.
// ++
import {OpenprojectCommonModule} from 'core-app/modules/common/openproject-common.module';
import {NgModule} from '@angular/core';
import {OpenprojectFieldsModule} from "core-app/modules/fields/openproject-fields.module";
import {TimeEntryEditService} from "core-app/modules/time_entries/edit/edit.service";
import {TimeEntryCreateModal} from "core-app/modules/time_entries/create/create.modal";
import {TimeEntryEditModal} from "core-app/modules/time_entries/edit/edit.modal";
import {TimeEntryFormComponent} from "core-app/modules/time_entries/form/form.component";
@NgModule({
imports: [
// Commons
OpenprojectCommonModule,
// Editable fields e.g. for modals
OpenprojectFieldsModule,
],
providers: [
],
declarations: [
TimeEntryEditModal,
TimeEntryCreateModal,
TimeEntryFormComponent
],
entryComponents: [
TimeEntryEditModal,
TimeEntryCreateModal,
],
exports: [
]
})
export class OpenprojectTimeEntriesModule {
}

@ -163,6 +163,7 @@ import {WorkPackageEditActionsBarComponent} from "core-app/modules/common/edit-a
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {WorkPackageChangeset} from "core-components/wp-edit/work-package-changeset";
import {WorkPackageSingleCardComponent} from "core-components/wp-card-view/wp-single-card/wp-single-card.component";
import { TimeEntryChangeset } from 'core-app/components/time-entries/time-entry-changeset';
@NgModule({
@ -515,11 +516,14 @@ export class OpenprojectWorkPackagesModule {
/** Return specialized work package changeset for editing service */
hookService.register('halResourceChangesetClass', (resource:HalResource) => {
if (resource._type === 'WorkPackage') {
return WorkPackageChangeset;
switch (resource._type) {
case 'WorkPackage':
return WorkPackageChangeset;
case 'TimeEntry':
return TimeEntryChangeset;
default:
return null;
}
return null;
});
};
}

@ -0,0 +1,51 @@
#-- 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 API
module V3
module TimeEntries
module AvailableWorkPackagesHelper
def available_work_packages_collection(allowed_scope)
service = WorkPackageCollectionFromQueryParamsService
.new(current_user, scope: allowed_scope)
.call(params)
if service.success?
service.result
else
api_errors = service.errors.full_messages.map do |message|
::API::Errors::InvalidQuery.new(message)
end
raise ::API::Errors::MultipleErrors.create_if_many api_errors
end
end
end
end
end
end

@ -0,0 +1,54 @@
#-- 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 API
module V3
module TimeEntries
class AvailableWorkPackagesOnCreateAPI < ::API::OpenProjectAPI
after_validation do
authorize_any %i[log_time],
global: true
end
helpers AvailableWorkPackagesHelper
helpers do
def allowed_scope
WorkPackage.where(project_id: Project.allowed_to(User.current, :log_time))
end
end
resources :available_work_packages do
get do
available_work_packages_collection(allowed_scope)
end
end
end
end
end
end

@ -0,0 +1,57 @@
#-- 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 API
module V3
module TimeEntries
class AvailableWorkPackagesOnEditAPI < ::API::OpenProjectAPI
after_validation do
authorize_any %i[edit_time_entries edit_own_time_entries],
projects: @time_entry.project
end
helpers AvailableWorkPackagesHelper
helpers do
def allowed_scope
edit_scope = WorkPackage.where(project_id: Project.allowed_to(User.current, :edit_time_entries))
edit_own_scope = WorkPackage.where(project_id: Project.allowed_to(User.current, :edit_own_time_entries))
edit_scope.or(edit_own_scope)
end
end
resources :available_work_packages do
get do
available_work_packages_collection(allowed_scope)
end
end
end
end
end
end

@ -56,6 +56,10 @@ module API
schema :user,
type: 'User'
schema :comment,
type: 'Formattable',
required: false
schema_with_allowed_link :work_package,
has_default: false,
required: false,
@ -83,13 +87,11 @@ module API
}
def allowed_work_package_href
api_v3_paths.path_for(:work_packages, filters: allowed_work_packages_filters)
end
def allowed_work_packages_filters
return unless represented.project
[{ project: { operator: '=', values: [represented.project.id.to_s] } }]
if represented.new_record?
api_v3_paths.time_entries_available_work_packages_on_create
else
api_v3_paths.time_entries_available_work_packages_on_edit(represented.id)
end
end
def allowed_projects_href

@ -39,6 +39,7 @@ module API
mount ::API::V3::TimeEntries::CreateFormAPI
mount ::API::V3::TimeEntries::Schemas::TimeEntrySchemaAPI
mount ::API::V3::TimeEntries::AvailableProjectsAPI
mount ::API::V3::TimeEntries::AvailableWorkPackagesOnCreateAPI
route_param :id, type: Integer, desc: 'Time entry ID' do
after_validation do
@ -52,6 +53,7 @@ module API
delete &::API::V3::Utilities::Endpoints::Delete.new(model: TimeEntry).mount
mount ::API::V3::TimeEntries::UpdateFormAPI
mount ::API::V3::TimeEntries::AvailableWorkPackagesOnEditAPI
end
mount ::API::V3::TimeEntries::TimeEntriesActivityAPI

@ -50,6 +50,15 @@ module API
}
end
link :update do
next unless update_allowed?
{
href: api_v3_paths.time_entry_form(represented.id),
method: :post
}
end
link :delete do
next unless update_allowed?
@ -59,6 +68,12 @@ module API
}
end
link :schema do
{
href: api_v3_paths.time_entry_schema
}
end
property :id
formattable_property :comments,
@ -71,10 +86,6 @@ module API
exec_context: :decorator,
getter: ->(*) do
datetime_formatter.format_duration_from_hours(represented.hours) if represented.hours
end,
setter: ->(fragment:, **) do
represented.hours = datetime_formatter.parse_duration_to_hours(fragment,
'hours')
end
date_time_property :created_on,
@ -115,6 +126,12 @@ module API
def current_user_allowed_to(permission, context:)
current_user.allowed_to?(permission, context)
end
def hours=(value)
represented.hours = datetime_formatter.parse_duration_to_hours(value,
'hours',
allow_nil: true)
end
end
end
end

@ -333,6 +333,14 @@ module API
"#{time_entries}/available_projects"
end
def self.time_entries_available_work_packages_on_create
"#{time_entries}/available_work_packages"
end
def self.time_entries_available_work_packages_on_edit(time_entry_id)
"#{time_entry(time_entry_id)}/available_work_packages"
end
index :type
show :type

@ -98,7 +98,6 @@ describe 'Status action board', type: :feature, js: true do
new_status_id: open_status.id)
}
let(:filters) { ::Components::WorkPackages::Filters.new }
before do

@ -93,7 +93,7 @@ describe 'Time entries widget on dashboard', type: :feature, js: true, with_mail
it 'adds the widget and checks the displayed entries' do
# within top-right area, add an additional widget
dashboard.add_widget(1, 1, :within, 'Spent time \(last 7 days\)')
dashboard.add_widget(1, 1, :within, 'Spent time')
spent_time_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)')

@ -38,7 +38,7 @@ en:
title: 'Subprojects'
no_results: 'No subprojects.'
time_entries_list:
title: 'Spent time (last 7 days)'
title: 'Spent time'
no_results: 'No time entries for the last 7 days.'
work_packages_accountable:
title: "Work packages I am accountable for"

@ -33,48 +33,78 @@ require_relative '../../support/pages/my/page'
describe 'My page time entries current user widget spec', type: :feature, js: true, with_mail: false do
let!(:type) { FactoryBot.create :type }
let!(:project) { FactoryBot.create :project, types: [type] }
let!(:activity) { FactoryBot.create :time_entry_activity }
let!(:other_activity) { FactoryBot.create :time_entry_activity }
let!(:work_package) do
FactoryBot.create :work_package,
project: project,
type: type,
author: user
end
let!(:other_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,
activity: activity,
user: user,
spent_on: Date.today,
hours: 6,
spent_on: Date.today.beginning_of_week(:sunday) + 1.day,
hours: 3,
comments: 'My comment'
end
let!(:other_visible_time_entry) do
FactoryBot.create :time_entry,
work_package: work_package,
project: project,
activity: activity,
user: user,
spent_on: Date.today - 1.day,
hours: 5,
spent_on: Date.today.beginning_of_week(:sunday) + 4.days,
hours: 2,
comments: 'My other comment'
end
let!(:last_week_visible_time_entry) do
FactoryBot.create :time_entry,
work_package: work_package,
project: project,
activity: activity,
user: user,
spent_on: Date.today - (Date.today.wday + 3).days,
hours: 8,
comments: 'My last week comment'
end
let!(:invisible_time_entry) do
FactoryBot.create :time_entry,
work_package: work_package,
project: project,
activity: activity,
user: other_user,
hours: 4
end
let!(:custom_field) do
FactoryBot.create :time_entry_custom_field, field_format: 'string'
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])
member_with_permissions: %i[view_time_entries edit_time_entries view_work_packages log_time])
end
let(:my_page) do
Pages::My::Page.new
end
let(:comments_field) { ::EditField.new(page, 'comment') }
let(:activity_field) { ::EditField.new(page, 'activity') }
let(:hours_field) { ::EditField.new(page, 'hours') }
let(:spent_on_field) { ::EditField.new(page, 'spentOn') }
let(:wp_field) { ::EditField.new(page, 'workPackage') }
let(:cf_field) { ::EditField.new(page, "customField#{custom_field.id}") }
before do
login_as user
@ -82,37 +112,177 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr
my_page.visit!
end
it 'adds the widget and checks the displayed entries' do
it 'adds the widget which then displays time entries and allows manipulating them' do
# within top-right area, add an additional widget
my_page.add_widget(1, 1, :within, 'Spent time \(last 7 days\)')
my_page.add_widget(1, 1, :within, 'Spent time')
entries_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)')
my_page.expect_and_dismiss_notification message: I18n.t(:notice_successful_update)
entries_area.expect_to_span(1, 1, 2, 2)
expect(page)
.to have_content "Total: 11.00"
.to have_content "Total: 5.00"
expect(page)
.to have_content Date.today.strftime('%m/%d/%Y')
.to have_content visible_time_entry.spent_on.strftime('%-m/%-d')
expect(page)
.to have_selector('.activity', text: visible_time_entry.activity.name)
.to have_selector('.fc-event .fc-title', text: "#{project.name} - ##{work_package.id}: #{work_package.subject}")
expect(page)
.to have_selector('.subject', text: "#{project.name} - ##{work_package.id}: #{work_package.subject}")
.to have_content(other_visible_time_entry.spent_on.strftime('%-m/%-d'))
expect(page)
.to have_selector('.comments', text: visible_time_entry.comments)
.to have_selector('.fc-event .fc-title', text: "#{project.name} - ##{work_package.id}: #{work_package.subject}")
# go to last week
within entries_area.area do
find('.fc-toolbar .fc-prev-button').click
end
expect(page)
.to have_selector('.hours', text: visible_time_entry.hours)
.to have_content "Total: 8.00"
expect(page)
.to have_content((Date.today - 1.day).strftime('%m/%d/%Y'))
.to have_content(last_week_visible_time_entry.spent_on.strftime('%-m/%-d'))
expect(page)
.to have_selector('.fc-event .fc-title', text: "#{project.name} - ##{work_package.id}: #{work_package.subject}")
# go to today again
within entries_area.area do
find('.fc-toolbar .fc-today-button').click
end
expect(page)
.to have_selector('.activity', text: other_visible_time_entry.activity.name)
.to have_content "Total: 5.00"
within entries_area.area do
find(".fc-content-skeleton td:nth-of-type(3) .fc-event-container .te-calendar--time-entry").hover
end
expect(page)
.to have_selector('.subject', text: "#{project.name} - ##{work_package.id}: #{work_package.subject}")
.to have_selector('.ui-tooltip', text: "Project: #{project.name}")
# Adding a time entry
# The add time entry event is invisible
within entries_area.area do
find(".fc-content-skeleton td:nth-of-type(5) .fc-event-container .te-calendar--add-entry", visible: false).click
end
expect(page)
.to have_selector('.comments', text: other_visible_time_entry.comments)
.to have_content(I18n.t('js.time_entry.work_package_required'))
spent_on_field.expect_value((Date.today.beginning_of_week(:sunday) + 3.days).strftime)
wp_field.input_element.click
wp_field.set_value(other_work_package.subject)
expect(page)
.to have_selector('.hours', text: other_visible_time_entry.hours)
.to have_no_content(I18n.t('js.time_entry.work_package_required'))
sleep(0.1)
comments_field.set_value('Comment for new entry')
activity_field.input_element.click
activity_field.set_value(activity.name)
hours_field.set_value('4')
sleep(0.1)
click_button I18n.t('js.label_create')
my_page.expect_and_dismiss_notification message: I18n.t(:notice_successful_create)
within entries_area.area do
expect(page)
.to have_selector(".fc-content-skeleton td:nth-of-type(5) .fc-event-container .te-calendar--time-entry",
text: other_work_package.subject)
end
expect(page)
.to have_content "Total: 9.00"
expect(TimeEntry.count)
.to eql 5
## Editing an entry
within entries_area.area do
find(".fc-content-skeleton td:nth-of-type(3) .fc-event-container .te-calendar--time-entry").click
end
expect(page)
.to have_content(I18n.t('js.time_entry.edit'))
activity_field.activate!
activity_field.set_value(other_activity.name)
activity_field.expect_display_value(other_activity.name)
wp_field.activate!
wp_field.set_value(other_work_package.subject)
wp_field.expect_display_value(other_work_package.name)
hours_field.activate!
hours_field.set_value('6')
hours_field.save!
hours_field.expect_display_value('6 h')
comments_field.activate!
comments_field.set_value('Some comment')
comments_field.save!
comments_field.expect_display_value('Some comment')
cf_field.activate!
cf_field.set_value('Cf text value')
cf_field.save!
cf_field.expect_display_value('Cf text value')
sleep(1)
find(".op-modal--portal .op-modal--modal-close-button").click
sleep(0.1)
my_page.expect_and_dismiss_notification message: I18n.t(:notice_successful_update)
within entries_area.area do
find(".fc-content-skeleton td:nth-of-type(3) .fc-event-container .te-calendar--time-entry").hover
end
expect(page)
.to have_selector('.ui-tooltip', text: "Comment: Some comment")
expect(page)
.to have_selector('.ui-tooltip', text: "Activity: #{other_activity.name}")
expect(page)
.to have_content "Total: 12.00"
## Removing the time entry
within entries_area.area do
find(".fc-content-skeleton td:nth-of-type(6) .fc-event-container .te-calendar--time-entry").click
end
click_button 'Delete'
within entries_area.area do
expect(page)
.not_to have_selector(".fc-content-skeleton td:nth-of-type(6) .fc-event-container .te-calendar--time-entry")
end
expect(page)
.to have_content "Total: 10.00"
expect(TimeEntry.where(id: other_visible_time_entry.id))
.not_to be_exist
# Removing the widget
entries_area.remove

@ -54,6 +54,7 @@ shared_examples_for 'time entry contract' do
let(:time_entry_hours) { 5 }
let(:time_entry_comments) { "A comment" }
let(:work_package_visible) { true }
let(:time_entry_day_sum) { 5 }
let(:activities_scope) do
scope = double('activities')
@ -77,7 +78,23 @@ shared_examples_for 'time entry contract' do
allow(TimeEntryActivity::Scopes::ActiveInProject)
.to receive(:fetch)
.and_return(TimeEntryActivity.where('1=0'))
.and_return(TimeEntryActivity.none)
of_user_and_day_scope = double('of_user_and_day_scope')
allow(TimeEntry::Scopes::OfUserAndDay)
.to receive(:fetch)
.and_return(TimeEntry.none)
allow(TimeEntry::Scopes::OfUserAndDay)
.to receive(:fetch)
.with(time_entry.user, time_entry_spent_on, excluding: time_entry)
.and_return(of_user_and_day_scope)
allow(of_user_and_day_scope)
.to receive(:sum)
.with(:hours)
.and_return(time_entry_day_sum)
allow(TimeEntryActivity::Scopes::ActiveInProject)
.to receive(:fetch)
@ -185,6 +202,14 @@ shared_examples_for 'time entry contract' do
it_behaves_like 'is valid'
end
context 'if more than 24 hours are booked for a day' do
let(:time_entry_day_sum) { 24 - time_entry_hours + 1 }
it 'is invalid' do
expect_valid(false, hours: %i(day_limit))
end
end
describe 'assignable_activities' do
context 'if no project is set' do
let(:time_entry_project) { nil }

@ -44,11 +44,12 @@ describe ::API::V3::TimeEntries::Schemas::TimeEntrySchemaRepresenter do
let(:contract) do
contract = double('contract',
new_record?: new_record,
id: new_record ? nil : 5,
project: assigned_project)
allow(contract)
.to receive(:writable?) do |attribute|
%w(spent_on hours project work_package activity).include?(attribute.to_s)
%w(spent_on hours project work_package activity comment).include?(attribute.to_s)
end
allow(contract)
@ -114,6 +115,17 @@ describe ::API::V3::TimeEntries::Schemas::TimeEntrySchemaRepresenter do
end
end
describe 'comment' do
let(:path) { 'comment' }
it_behaves_like 'has basic schema properties' do
let(:type) { 'Formattable' }
let(:name) { TimeEntry.human_attribute_name('comment') }
let(:required) { false }
let(:writable) { true }
end
end
describe 'createdAt' do
let(:path) { 'createdAt' }
@ -160,41 +172,22 @@ describe ::API::V3::TimeEntries::Schemas::TimeEntrySchemaRepresenter do
context 'if embedding' do
let(:embedded) { true }
context 'if having no project' do
it_behaves_like 'links to allowed values via collection link' do
let(:href) do
api_v3_paths.work_packages
end
end
end
context 'if having a project' do
let(:assigned_project) { project }
context 'if being a new record' do
let(:new_record) { true }
it_behaves_like 'links to allowed values via collection link' do
let(:href) do
api_v3_paths.path_for(:work_packages, filters: [{ project: { operator: '=', values: [project.id.to_s] } }])
api_v3_paths.time_entries_available_work_packages_on_create
end
end
end
end
describe 'project' do
let(:path) { 'project' }
it_behaves_like 'has basic schema properties' do
let(:type) { 'Project' }
let(:name) { TimeEntry.human_attribute_name('project') }
let(:required) { false }
let(:writable) { true }
end
context 'if embedding' do
let(:embedded) { true }
context 'if being an existing record' do
let(:new_record) { false }
it_behaves_like 'links to allowed values via collection link' do
let(:href) do
api_v3_paths.time_entries_available_projects
api_v3_paths.time_entries_available_work_packages_on_edit(contract.id)
end
end
end

@ -33,13 +33,13 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do
let(:time_entry) do
FactoryBot.build_stubbed(:time_entry,
comments: 'blubs',
spent_on: Date.today - 3.days,
created_on: DateTime.now - 6.hours,
updated_on: DateTime.now - 3.hours,
activity: activity,
project: project,
user: user)
comments: 'blubs',
spent_on: Date.today - 3.days,
created_on: DateTime.now - 6.hours,
updated_on: DateTime.now - 3.hours,
activity: activity,
project: project,
user: user)
end
let(:project) { FactoryBot.build_stubbed(:project) }
let(:project2) { FactoryBot.build_stubbed(:project) }
@ -143,6 +143,20 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do
expect(time_entry.hours)
.to eql(5.0)
end
context 'with null value' do
let(:hash) do
{
"hours" => nil
}
end
it 'updates hours' do
time_entry = representer.from_hash(hash)
expect(time_entry.hours)
.to eql(nil)
end
end
end
context 'comment' do

@ -99,6 +99,11 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do
let(:title) { activity.name }
end
it_behaves_like 'has an untitled link' do
let(:link) { 'schema' }
let(:href) { api_v3_paths.time_entry_schema }
end
context 'custom value' do
let(:custom_field) do
FactoryBot.build_stubbed(:time_entry_custom_field, field_format: 'user')
@ -141,6 +146,12 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do
let(:method) { :patch }
end
it_behaves_like 'has an untitled link' do
let(:link) { 'update' }
let(:href) { api_v3_paths.time_entry_form(time_entry.id) }
let(:method) { :post }
end
it_behaves_like 'has an untitled link' do
let(:link) { 'delete' }
let(:href) { api_v3_paths.time_entry(time_entry.id) }
@ -154,6 +165,10 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do
let(:link) { 'updateImmediately' }
end
it_behaves_like 'has no link' do
let(:link) { 'update' }
end
it_behaves_like 'has no link' do
let(:link) { 'delete' }
end

@ -228,13 +228,13 @@ describe Changeset, type: :model do
'15m' => 0.25,
'15min' => 0.25,
'3h15' => 3.25,
'3h15m' => 3.25,
'3h15min' => 3.25,
'3:15' => 3.25,
'3.25' => 3.25,
'3.25h' => 3.25,
'3,25' => 3.25,
'3,25h' => 3.25
'2h15m' => 2.25,
'2h15min' => 2.25,
'2:15' => 2.25,
'2.25' => 2.25,
'1.25h' => 1.25,
'0,75' => 0.75,
'1,25h' => 1.25
}.each do |syntax, expected_hours|
c = Changeset.new repository: repository,
committed_on: 24.hours.ago,

@ -0,0 +1,74 @@
#-- 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.
#++
require 'spec_helper'
describe TimeEntry::Scopes::OfUserAndDay, type: :model do
let(:user) { FactoryBot.create(:user) }
let(:spent_on) { Date.today }
let!(:time_entry) do
FactoryBot.create(:time_entry,
user: user,
spent_on: spent_on)
end
let!(:other_time_entry) do
FactoryBot.create(:time_entry,
user: user,
spent_on: spent_on)
end
let!(:other_user_time_entry) do
FactoryBot.create(:time_entry,
user: FactoryBot.create(:user),
spent_on: spent_on)
end
let!(:other_date_time_entry) do
FactoryBot.create(:time_entry,
user: user,
spent_on: spent_on - 3.days)
end
describe '.fetch' do
subject { described_class.fetch(user, spent_on) }
it 'are all the time entries of the user on the date' do
is_expected
.to match_array([time_entry, other_time_entry])
end
context 'if excluding a time entry' do
subject { described_class.fetch(user, spent_on, excluding: other_time_entry) }
it 'does not include the time entry' do
is_expected
.to match_array([time_entry])
end
end
end
end

@ -176,7 +176,7 @@ describe ::API::V3::TimeEntries::CreateFormAPI, content_type: :json do
it 'has the available values listed in the schema' do
body = subject.body
wp_path = api_v3_paths.path_for(:work_packages, filters: [{ project: { operator: '=', values: [project.id.to_s] } }])
wp_path = api_v3_paths.time_entries_available_work_packages_on_create
expect(body)
.to be_json_eql(wp_path.to_json)

@ -149,7 +149,7 @@ describe ::API::V3::TimeEntries::UpdateFormAPI, content_type: :json do
it 'has the available values listed in the schema' do
body = subject.body
wp_path = api_v3_paths.path_for(:work_packages, filters: [{ project: { operator: '=', values: [project.id.to_s] } }])
wp_path = api_v3_paths.time_entries_available_work_packages_on_edit(time_entry.id)
expect(body)
.to be_json_eql(wp_path.to_json)

@ -65,7 +65,7 @@ describe ::API::V3::WorkPackageCollectionFromQueryParamsService,
allow(::API::V3::WorkPackageCollectionFromQueryService)
.to receive(:new)
.with(query, user)
.with(query, user, scope: nil)
.and_return(mock_wp_collection_from_query_service)
end

@ -51,6 +51,11 @@ class EditField
expect(input_element.value).to eq(value)
end
def expect_display_value(value)
expect(display_element)
.to have_content(value)
end
##
# Activate the field and check it opened correctly
def activate!(expect_open: true)
@ -222,8 +227,11 @@ class EditField
'status',
'project',
'type',
'category'
'category',
'workPackage'
'create-autocompleter'
when 'activity'
'activity-autocompleter'
else
:input
end.to_s

Loading…
Cancel
Save