diff --git a/Gemfile.lock b/Gemfile.lock index ef726035ab5e..4d8514bbc042 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -789,7 +789,7 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.1) + nokogiri (1.18.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.9) diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index dc2e45c2eea4..c3db5148528f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -234,6 +234,9 @@ import { appBaseSelector, ApplicationBaseComponent } from 'core-app/core/routing import { SpotSwitchComponent } from 'core-app/spot/components/switch/switch.component'; import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { + TimeEntriesWorkPackageAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component'; export function initializeServices(injector:Injector) { return () => { @@ -425,6 +428,7 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-project-autocompleter', ProjectAutocompleterComponent, { injector }); registerCustomElement('opce-members-autocompleter', MembersAutocompleterComponent, { injector }); registerCustomElement('opce-user-autocompleter', UserAutocompleterComponent, { injector }); + registerCustomElement('opce-time-entries-work-package-autocompleter', TimeEntriesWorkPackageAutocompleterComponent, { injector }); registerCustomElement('opce-macro-attribute-value', AttributeValueMacroComponent, { injector }); registerCustomElement('opce-macro-attribute-label', AttributeLabelMacroComponent, { injector }); registerCustomElement('opce-macro-wp-quickinfo', WorkPackageQuickinfoMacroComponent, { injector }); diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html index 5afe822ca981..e6067ca49e54 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html @@ -4,6 +4,7 @@ *ngIf="inputName && !(multiple && multipleAsSeparateInputs)" type="hidden" [attr.name]="inputName" + [value]="mappedInputValue" [attr.data-action]="hiddenFieldAction"> boolean; + @Input() public searchFn:(term:string, item:unknown) => boolean; @Input() public trackByFn = this.defaultTrackByFunction(); @@ -293,7 +293,7 @@ export class OpAutocompleterComponent { - this.model = resource as unknown as T; - this.syncHiddenField(this.mappedInputValue); - this.cdRef.detectChanges(); - }); - } } ngOnChanges(changes:SimpleChanges):void { @@ -343,6 +332,17 @@ export class OpAutocompleterComponent { + this.model = resource as unknown as T; + this.syncHiddenField(this.mappedInputValue); + this.cdRef.detectChanges(); + }); + } + this.ngZone.runOutsideAngular(() => { setTimeout(() => { this.results$ = merge( @@ -385,7 +385,7 @@ export class OpAutocompleterComponent { + public getOptionsItems(searchKey:string):Observable { return of((this.items as IOPAutocompleterOption[])?.filter((element) => element.name.includes(searchKey))); } @@ -414,6 +414,7 @@ export class OpAutocompleterComponent - -
-
- -
-
-
- diff --git a/frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.sass b/frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.sass deleted file mode 100644 index 7e1acc00cab4..000000000000 --- a/frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.sass +++ /dev/null @@ -1,2 +0,0 @@ -.ng-dropdown-panel .ng-dropdown-header - padding-bottom: 0 \ No newline at end of file diff --git a/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter-template.component.html b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter-template.component.html new file mode 100644 index 000000000000..967cdbad9c49 --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter-template.component.html @@ -0,0 +1,32 @@ + +
+
+ +
+
+
diff --git a/frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter-template.component.ts similarity index 54% rename from frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.ts rename to frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter-template.component.ts index 2932930c92be..e7e89b1e94cf 100644 --- a/frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter-template.component.ts @@ -26,44 +26,30 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { ChangeDetectionStrategy, Component, Injector, Input, TemplateRef, ViewChild } from '@angular/core'; import { - AfterViewInit, - Component, - EventEmitter, - Injector, - Output, - ViewEncapsulation, -} from '@angular/core'; -import { WorkPackageAutocompleterComponent } from 'core-app/shared/components/autocompleter/work-package-autocompleter/wp-autocompleter.component'; - -export type TimeEntryWorkPackageAutocompleterMode = 'all'|'recent'; + IAutocompleterTemplateComponent, + OpAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; +import { + TimeEntriesWorkPackageAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component'; @Component({ - templateUrl: './te-work-package-autocompleter.component.html', - styleUrls: [ - './te-work-package-autocompleter.component.sass', - ], - selector: 'te-work-package-autocompleter', - encapsulation: ViewEncapsulation.None, + templateUrl: './time-entries-work-package-autocompleter-template.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TimeEntryWorkPackageAutocompleterComponent extends WorkPackageAutocompleterComponent implements AfterViewInit { - @Output() modeSwitch = new EventEmitter(); +export class TimeEntriesWorkPackageAutocompleterTemplateComponent implements IAutocompleterTemplateComponent { + @Input() public mode:string|undefined; + @Input() public isOpenedInModal:boolean = false; + @Input() public hoverCards:boolean = true; + + @ViewChild('headerTemplate') headerTemplate:TemplateRef; + + autocompleter:TimeEntriesWorkPackageAutocompleterComponent = this.injector.get(OpAutocompleterComponent) as TimeEntriesWorkPackageAutocompleterComponent; constructor( readonly injector:Injector, ) { - super(injector); - - this.text.all = this.I18n.t('js.label_all'); - this.text.recent = this.I18n.t('js.label_recent'); - } - - public mode:TimeEntryWorkPackageAutocompleterMode = 'all'; - - public setMode(value:TimeEntryWorkPackageAutocompleterMode) { - if (value !== this.mode) { - this.modeSwitch.emit(value); - } - this.mode = value; } } diff --git a/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component.ts new file mode 100644 index 000000000000..1c556f037d28 --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component.ts @@ -0,0 +1,156 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, Component, forwardRef, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { OpInviteUserModalService } from 'core-app/features/invite-user-modal/invite-user-modal.service'; +import { + OpAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { map, switchMap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import idFromLink from 'core-app/features/hal/helpers/id-from-link'; +import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; +import { TimeEntryResource } from 'core-app/features/hal/resources/time-entry-resource'; +import { IAPIFilter } from 'core-app/shared/components/autocompleter/op-autocompleter/typings'; +import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { HalResourceSortingService } from 'core-app/features/hal/services/hal-resource-sorting.service'; +import { + TimeEntriesWorkPackageAutocompleterTemplateComponent, +} from 'core-app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter-template.component'; + +export type TimeEntryWorkPackageAutocompleterMode = 'all'|'recent'; + +const RECENT_TIME_ENTRIES_MAGIC_NUMBER = 30; + +@Component({ + templateUrl: '../op-autocompleter/op-autocompleter.component.html', + selector: 'op-time-entries-work-package-autocompleter', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeEntriesWorkPackageAutocompleterComponent), + multi: true, + }, + // Provide a new version of the modal invite service, + // as otherwise the close event will be shared across all instances + OpInviteUserModalService, + ], +}) +export class TimeEntriesWorkPackageAutocompleterComponent extends OpAutocompleterComponent implements OnInit, ControlValueAccessor { + public mode:TimeEntryWorkPackageAutocompleterMode = 'all'; + + @InjectField() halSorting:HalResourceSortingService; + + labelAll = this.I18n.t('js.label_all'); + labelRecent = this.I18n.t('js.label_recent'); + + private recentWorkPackageIds:string[]; + + getOptionsFn = this.loadAllowedValues.bind(this); + + ngOnInit():void { + super.ngOnInit(); + this.applyTemplates(TimeEntriesWorkPackageAutocompleterTemplateComponent); + } + + changeMode(newMode:TimeEntryWorkPackageAutocompleterMode) { + this.mode = newMode; + + if (this.typeahead) { + const lastValue = this.typeahead?.value; + this.typeahead?.next(' '); // Reset value + this.typeahead?.next(lastValue); + } + + this.cdRef.detectChanges(); + } + + // We fetch the last RECENT_TIME_ENTRIES_MAGIC_NUMBER time entries by that user. We then use it to fetch the work packages + // associated with the time entries so that we have the most recent work packages the user logged time on. + // As a worst case, the user logged RECENT_TIME_ENTRIES_MAGIC_NUMBER times on one work package so we can not guarantee to actually have + // a fixed number returned. + protected loadAllowedValues(query:string):Observable { + if (!this.recentWorkPackageIds) { + return this + .apiV3Service + .time_entries + .list({ + filters: [['user_id', '=', ['me']]], + sortBy: [['updated_at', 'desc']], + pageSize: RECENT_TIME_ENTRIES_MAGIC_NUMBER, + }) + .pipe( + switchMap((collection:CollectionResource) => { + this.recentWorkPackageIds = collection + .elements + .filter((timeEntry) => timeEntry.workPackage?.href) + .map((timeEntry) => idFromLink(timeEntry.workPackage.href)) + .filter((v, i, a) => a.indexOf(v) === i); + + return this.loadWorkPackages(query); + }), + ); + } + + return this.loadWorkPackages(query); + } + + protected get modeSpecificFilters():IAPIFilter[] { + const base = this.filters ?? []; + const isRecent = this.mode === 'recent'; + if (isRecent && this.recentWorkPackageIds.length > 0) { + return [...base, { name: 'id', operator: '=', values: this.recentWorkPackageIds } as IAPIFilter]; + } + + return base; + } + + protected loadWorkPackages(query:string):Observable { + return this + .opAutocompleterService + .loadData(query, this.resource, this.modeSpecificFilters, this.searchKey) + .pipe( + map((workPackages) => this.sortValues(workPackages)), + ); + } + + protected sortValues(availableValues:HalResource[]) { + if (this.mode === 'recent') { + return this.sortValuesByRecentIds(availableValues); + } + return this.halSorting.sort(availableValues); + } + + protected sortValuesByRecentIds(availableValues:HalResource[]) { + return availableValues + .sort((a, b) => this.recentWorkPackageIds.indexOf(a.id!) - this.recentWorkPackageIds.indexOf(b.id!)); + } +} diff --git a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts index 4164daa06fb6..8132f9789e81 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts @@ -61,9 +61,6 @@ import { import { PlainFormattableEditFieldComponent, } from 'core-app/shared/components/fields/edit/field-types/plain-formattable-edit-field.component'; -import { - TimeEntryWorkPackageEditFieldComponent, -} from 'core-app/shared/components/fields/edit/field-types/te-work-package-edit-field.component'; import { CombinedDateEditFieldComponent, } from 'core-app/shared/components/fields/edit/field-types/combined-date-edit-field.component'; @@ -141,7 +138,6 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele ) .addSpecificFieldType('Project', ProjectStatusEditFieldComponent, 'status', ['status']) .addSpecificFieldType('TimeEntry', PlainFormattableEditFieldComponent, 'comment', ['comment']) - .addSpecificFieldType('TimeEntry', TimeEntryWorkPackageEditFieldComponent, 'workPackage', ['WorkPackage']) .addSpecificFieldType('TimeEntry', HoursDurationEditFieldComponent, 'hours', ['hours']); selectAutocompleterRegisterService.register(VersionAutocompleterComponent, 'Version'); diff --git a/frontend/src/app/shared/components/fields/edit/field-types/te-work-package-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/te-work-package-edit-field.component.ts deleted file mode 100644 index 2b850bad15db..000000000000 --- a/frontend/src/app/shared/components/fields/edit/field-types/te-work-package-edit-field.component.ts +++ /dev/null @@ -1,137 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) 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 COPYRIGHT and LICENSE files for more details. -//++ - -import { Component } from '@angular/core'; -import { WorkPackageEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/work-package-edit-field.component'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import idFromLink from 'core-app/features/hal/helpers/id-from-link'; -import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { - TimeEntryWorkPackageAutocompleterComponent, - TimeEntryWorkPackageAutocompleterMode, -} from 'core-app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component'; -import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; -import { firstValueFrom } from 'rxjs'; - -const RECENT_TIME_ENTRIES_MAGIC_NUMBER = 30; - -@Component({ - templateUrl: './work-package-edit-field.component.html', -}) -export class TimeEntryWorkPackageEditFieldComponent extends WorkPackageEditFieldComponent { - @InjectField() apiV3Service:ApiV3Service; - - private recentWorkPackageIds:string[]; - - protected initialize() { - super.initialize(); - - // For reasons beyond me, the referenceOutputs variable is not defined at first when editing - // existing values. - if (this.referenceOutputs) { - this.referenceOutputs.modeSwitch = (mode:TimeEntryWorkPackageAutocompleterMode) => { - this.valuesLoaded = false; - const lastValue = this.requests.lastRequestedValue!; - - // Hack to provide a new value to "reset" the input. - // Only the second input is actually processed as the input is debounced. - this.requests.input$.next('_/&"()____'); - this.requests.input$.next(lastValue); - }; - } - } - - public autocompleterComponent() { - return TimeEntryWorkPackageAutocompleterComponent; - } - - // 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; - } - - // We fetch the last RECENT_TIME_ENTRIES_MAGIC_NUMBER time entries by that user. We then use it to fetch the work packages - // associated with the time entries so that we have the most recent work packages the user logged time on. - // As a worst case, the user logged RECENT_TIME_ENTRIES_MAGIC_NUMBER times on one work package so we can not guarantee to actually have - // a fixed number returned. - protected loadAllowedValues(query?:string) { - if (!this.recentWorkPackageIds) { - return firstValueFrom( - this - .apiV3Service - .time_entries - .list({ - filters: [['user_id', '=', ['me']]], - sortBy: [['updated_at', 'desc']], - pageSize: RECENT_TIME_ENTRIES_MAGIC_NUMBER, - }), - ).then((collection) => { - this.recentWorkPackageIds = collection - .elements - .filter((timeEntry) => timeEntry.workPackage?.href) - .map((timeEntry) => idFromLink(timeEntry.workPackage.href)) - .filter((v, i, a) => a.indexOf(v) === i); - - return this.fetchAllowedValueQuery(query); - }); - } - return this.fetchAllowedValueQuery(query); - } - - protected allowedValuesFilter(query?:string):{} { - const filters:ApiV3FilterBuilder = new ApiV3FilterBuilder(); - - const isRecent = (this._autocompleterComponent as TimeEntryWorkPackageAutocompleterComponent).mode === 'recent'; - if (isRecent && this.recentWorkPackageIds.length > 0) { - filters.add('id', '=', this.recentWorkPackageIds); - } - - if (query) { - filters.add('subjectOrId', '**', [query]); - } - - return { filters: filters.toJson() }; - } - - protected sortValues(availableValues:HalResource[]) { - if ((this._autocompleterComponent as TimeEntryWorkPackageAutocompleterComponent).mode === 'recent') { - return this.sortValuesByRecentIds(availableValues); - } - return super.sortValues(availableValues); - } - - protected sortValuesByRecentIds(availableValues:HalResource[]) { - return availableValues - .sort((a, b) => this.recentWorkPackageIds.indexOf(a.id!) - this.recentWorkPackageIds.indexOf(b.id!)); - } -} diff --git a/frontend/src/app/shared/components/fields/openproject-fields.module.ts b/frontend/src/app/shared/components/fields/openproject-fields.module.ts index 39f0b5f71e30..ffb58fa6bad5 100644 --- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts +++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts @@ -61,9 +61,6 @@ import { import { PlainFormattableEditFieldComponent, } from 'core-app/shared/components/fields/edit/field-types/plain-formattable-edit-field.component'; -import { - TimeEntryWorkPackageEditFieldComponent, -} from 'core-app/shared/components/fields/edit/field-types/te-work-package-edit-field.component'; import { AttributeValueMacroComponent } from 'core-app/shared/components/fields/macros/attribute-value-macro.component'; import { AttributeLabelMacroComponent } from 'core-app/shared/components/fields/macros/attribute-label-macro.component'; import { @@ -98,7 +95,9 @@ import { ProjectEditFieldComponent } from './edit/field-types/project-edit-field import { HoursDurationEditFieldComponent, } from 'core-app/shared/components/fields/edit/field-types/hours-duration-edit-field.component'; -import { ProgressPopoverEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/progress-popover-edit-field.component'; +import { + ProgressPopoverEditFieldComponent, +} from 'core-app/shared/components/fields/edit/field-types/progress-popover-edit-field.component'; import { OpExclusionInfoComponent } from 'core-app/shared/components/fields/display/info/op-exclusion-info.component'; import { UserEditFieldComponent } from './edit/field-types/user-edit-field.component'; import { @@ -162,7 +161,6 @@ import { FormsModule } from '@angular/forms'; ProjectEditFieldComponent, UserEditFieldComponent, WorkPackageEditFieldComponent, - TimeEntryWorkPackageEditFieldComponent, EditFormComponent, DisplayFieldComponent, EditableAttributeFieldComponent, diff --git a/modules/costs/app/components/time_entries/work_package_form.rb b/modules/costs/app/components/time_entries/work_package_form.rb index b5fae9a69b7c..b351089af42a 100644 --- a/modules/costs/app/components/time_entries/work_package_form.rb +++ b/modules/costs/app/components/time_entries/work_package_form.rb @@ -43,6 +43,8 @@ def initialize(visible: true) label: TimeEntry.human_attribute_name(:work_package), required: true, autocomplete_options: { + defaultData: false, + component: "opce-time-entries-work-package-autocompleter", hiddenFieldAction: "change->time-entry#workPackageChanged", focusDirectly: false, append_to: "#time-entry-dialog", diff --git a/modules/costs/app/services/time_entries/set_attributes_service.rb b/modules/costs/app/services/time_entries/set_attributes_service.rb index acc656691399..65cbe60e3e27 100644 --- a/modules/costs/app/services/time_entries/set_attributes_service.rb +++ b/modules/costs/app/services/time_entries/set_attributes_service.rb @@ -58,7 +58,6 @@ def set_attributes(_attributes) # rubocop:disable Metrics/AbcSize def set_default_attributes(*) set_default_user set_default_hours - set_default_activity if model.activity.nil? end def set_logged_by @@ -73,28 +72,6 @@ def set_default_user end end - def set_default_activity - return unless TimeEntryActivity.default - - if model.project - assign_default_project_activity - else - assign_default_activity - end - end - - def assign_default_project_activity - if TimeEntryActivity.active_in_project(model.project).exists?(id: TimeEntryActivity.default.id) - assign_default_activity - end - end - - def assign_default_activity - model.change_by_system do - model.activity = TimeEntryActivity.default - end - end - def set_default_hours model.hours = nil if model.hours&.zero? end diff --git a/modules/costs/spec/features/timer_spec.rb b/modules/costs/spec/features/timer_spec.rb index d3260ffda5ed..5dbfd03edc18 100644 --- a/modules/costs/spec/features/timer_spec.rb +++ b/modules/costs/spec/features/timer_spec.rb @@ -100,7 +100,7 @@ expect(timer_entry.work_package).to eq work_package_a expect(timer_entry.hours).to be_nil - page.within(".spot-modal") { click_button "Stop current timer" } + page.within(".spot-modal") { click_on "Stop current timer" } time_logging_modal.is_visible true time_logging_modal.has_field_with_value "spent_on", Date.current.strftime time_logging_modal.has_field_with_value "hours", /(\d\.)?\d+/ diff --git a/modules/costs/spec/services/time_entries/set_attributes_service_spec.rb b/modules/costs/spec/services/time_entries/set_attributes_service_spec.rb index d6b28c6de02c..f04a650832ba 100644 --- a/modules/costs/spec/services/time_entries/set_attributes_service_spec.rb +++ b/modules/costs/spec/services/time_entries/set_attributes_service_spec.rb @@ -40,14 +40,12 @@ let(:hours) { 5.0 } let(:comments) { "some comment" } let(:contract_instance) do - contract = double("contract_instance") # rubocop:disable RSpec/VerifiedDoubles - allow(contract) - .to receive(:validate) - .and_return(contract_valid) - allow(contract) - .to receive(:errors) - .and_return(contract_errors) - contract + double("contract_instance").tap do |contract| # rubocop:disable RSpec/VerifiedDoubles + allow(contract).to receive_messages( + validate: contract_valid, + errors: contract_errors + ) + end end let(:contract_errors) { double("contract_errors") } # rubocop:disable RSpec/VerifiedDoubles @@ -103,17 +101,6 @@ .to eql [nil, user.id] end - it "assigns the default TimeEntryActivity" do - allow(TimeEntryActivity) - .to receive(:default) - .and_return(default_activity) - - subject - - expect(time_entry_instance.activity) - .to eql default_activity - end - context "with params" do let(:params) do { diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 5dedc984d3ad..cbf5d1daa162 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -192,7 +192,7 @@ def upcoming_meetings(count:) end # Ensure we keep any remaining future meetings that exceed the limit - merged + meetings.values.sort_by(&:start_time) + (merged + meetings.values).sort_by(&:start_time) end def scheduled_meeting(start_time) diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index f4990f3d626e..47c3037aef5f 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -67,11 +67,14 @@ def meeting_series_menu_items series = series.where(project_id: project.id) end + current_href = params[:current_href] + current_recurring_meeting_id = extracted_id(current_href) + series.pluck(:id, :title) .map do |id, title| href = polymorphic_path([project, :recurring_meeting], { id: }) OpenProject::Menu::MenuItem.new(title:, - selected: params[:current_href] == href, + selected: select_status(href, current_href, current_recurring_meeting_id), href:) end end @@ -124,5 +127,19 @@ def author_filter def recurring_meeting_type_filter [{ type: { operator: "=", values: [RecurringMeeting.to_s] } }].to_json end + + def extracted_id(current_href) + current_meeting_id = current_href.split("/").last.to_i if current_href&.match(/\/meetings\/\d+$/) + + Meeting.find(current_meeting_id).recurring_meeting_id if current_meeting_id + end + + def select_status(href, current_href, current_recurring_meeting_id = nil) + return current_href == href unless current_recurring_meeting_id && !href.is_a?(Hash) + + href_meeting_id = href.split("/").last.to_i + + current_recurring_meeting_id == href_meeting_id + end end end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 092ebdc56a6a..d7b565495b98 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -1,3 +1,33 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 COPYRIGHT and LICENSE files for more details. +#++ + class RecurringMeeting < ApplicationRecord # Magical maximum of iterations MAX_ITERATIONS = 1000 @@ -83,7 +113,7 @@ def date end def schedule - @schedule ||= IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| + @schedule ||= IceCube::Schedule.new(start_time, end_time: modified_end_date).tap do |s| s.add_recurrence_rule count_rule(frequency_rule) exclude_non_working_days(s) if frequency_working_days? end @@ -139,7 +169,7 @@ def next_occurrence(from_time: Time.current) def remaining_occurrences if end_date.present? - schedule.occurrences_between(Time.current, end_date) + schedule.occurrences_between(Time.current, modified_end_date) else schedule.remaining_occurrences(Time.current) end @@ -162,6 +192,11 @@ def unset_schedule @schedule = nil end + # Because IceCube is exclusive by default for end_time for a schedule + def modified_end_date + @modified_end_date ||= end_date + 1.day + end + def end_date_constraints return if end_date.nil? @@ -202,7 +237,7 @@ def count_rule(rule) if end_after_iterations? rule.count(iterations) else - rule.until(end_date.to_time(:utc)) + rule.until(modified_end_date.to_time(:utc)) end end diff --git a/modules/meeting/spec/models/recurring_meeting_spec.rb b/modules/meeting/spec/models/recurring_meeting_spec.rb index 5497bace6a7a..a17dd52006b2 100644 --- a/modules/meeting/spec/models/recurring_meeting_spec.rb +++ b/modules/meeting/spec/models/recurring_meeting_spec.rb @@ -118,7 +118,7 @@ it "schedules daily", :aggregate_failures do expect(subject.first_occurrence).to eq Time.zone.tomorrow + 10.hours - expect(subject.last_occurrence).to eq Time.zone.tomorrow + 6.days + 10.hours + expect(subject.last_occurrence).to eq Time.zone.tomorrow + 7.days + 10.hours occurrence_in_two_days = Time.zone.today + 2.days + 10.hours Timecop.freeze(Time.zone.tomorrow + 11.hours) do @@ -186,7 +186,7 @@ it "schedules weekly", :aggregate_failures do expect(subject.first_occurrence).to eq Time.zone.tomorrow + 10.hours - expect(subject.last_occurrence).to eq Time.zone.tomorrow + 3.weeks + 10.hours + expect(subject.last_occurrence).to eq Time.zone.tomorrow + 4.weeks + 10.hours following_occurrence = Time.zone.tomorrow + 7.days + 10.hours Timecop.freeze(Time.zone.tomorrow + 11.hours) do @@ -198,7 +198,8 @@ Time.zone.tomorrow + 10.hours, Time.zone.tomorrow + 7.days + 10.hours, Time.zone.tomorrow + 14.days + 10.hours, - Time.zone.tomorrow + 21.days + 10.hours + Time.zone.tomorrow + 21.days + 10.hours, + Time.zone.tomorrow + 28.days + 10.hours ] Timecop.freeze(Time.zone.tomorrow + 5.weeks) do diff --git a/modules/reporting/app/helpers/reporting_helper.rb b/modules/reporting/app/helpers/reporting_helper.rb index 35f5ffe14e62..822fe88b68da 100644 --- a/modules/reporting/app/helpers/reporting_helper.rb +++ b/modules/reporting/app/helpers/reporting_helper.rb @@ -141,6 +141,28 @@ def field_representation_map(key, value) end # rubocop:enable Metrics/AbcSize + def spent_on_time_representation(start_timestamp, hours) + return "" if start_timestamp.nil? + + result = format_time(start_timestamp, include_date: false) + return result if hours.nil? || hours.zero? + + end_timestamp = start_timestamp + hours.hours + days_between_suffix = days_between_representation(start_timestamp, end_timestamp) + "#{result} - #{format_time(end_timestamp, include_date: false)}#{days_between_suffix}" + end + + def days_between_representation(start_timestamp, end_timestamp) + return "" if start_timestamp.nil? || end_timestamp.nil? + + days_between = (end_timestamp.to_date - start_timestamp.to_date).to_i + if days_between.positive? + " (+#{WorkPackage::Exports::Formatters::Days.new(nil) + .format_value(days_between, nil) + .delete(' ')})" + end + end + def custom_value(cf_identifier, value) cf_id = cf_identifier.gsub("custom_field", "").to_i diff --git a/modules/reporting/app/models/cost_query/result.rb b/modules/reporting/app/models/cost_query/result.rb index 32b5d4bb38e5..8690ae253e27 100644 --- a/modules/reporting/app/models/cost_query/result.rb +++ b/modules/reporting/app/models/cost_query/result.rb @@ -44,6 +44,7 @@ class Base < Report::Result::Base class DirectResult < Report::Result::DirectResult include BaseAdditions + def display_costs self["display_costs"].to_i end @@ -51,10 +52,32 @@ def display_costs def real_costs (self["real_costs"] || 0).to_d if display_costs? # FIXME: default value here? end + + def start_timestamp + return nil if self["start_time"].blank? || self["time_zone"].blank? || self["spent_on"].blank? + + timestamp(Date.parse(self["spent_on"]), self["start_time"], self["time_zone"]) + end + + def end_timestamp + return nil if self["units"].blank? + + start = start_timestamp + return nil if start.nil? + + start + self["units"].to_f.hours + end + + private + + def timestamp(date, time, time_zone) + ActiveSupport::TimeZone[time_zone].local(date.year, date.month, date.day, time / 60, time % 60) + end end class WrappedResult < Report::Result::WrappedResult include BaseAdditions + def display_costs (sum_for :display_costs) >= 1 ? 1 : 0 end diff --git a/modules/reporting/app/models/cost_query/sql_statement.rb b/modules/reporting/app/models/cost_query/sql_statement.rb index 973eaf8d14c7..d43b2dc2920c 100644 --- a/modules/reporting/app/models/cost_query/sql_statement.rb +++ b/modules/reporting/app/models/cost_query/sql_statement.rb @@ -74,6 +74,8 @@ def to_s # cost_type_id | -1 | cost_type_id # type | "TimeEntry" | "CostEntry" # count | 1 | 1 + # start_time | start_time | nil + # time_zone | time_zone | nil # # Also: This _should_ handle joining activities and cost_types, as the logic differs for time_entries # and cost_entries. @@ -102,7 +104,7 @@ def self.unified_entry(model) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_time_entries(query) - query.select :activity_id, :logged_by_id, units: :hours, cost_type_id: -1 + query.select :activity_id, :logged_by_id, :start_time, :time_zone, units: :hours, cost_type_id: -1 query.select cost_type: quoted_label(:caption_labor) end @@ -111,7 +113,7 @@ def self.unify_time_entries(query) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_cost_entries(query) - query.select :units, :cost_type_id, :logged_by_id, activity_id: -1 + query.select :units, :cost_type_id, :logged_by_id, activity_id: -1, start_time: nil, time_zone: nil query.select cost_type: "cost_types.name" query.join CostType end diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 4c2735ab7d5b..b32cef527e2a 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -5,6 +5,7 @@ class CostQuery::PDF::TimesheetGenerator include WorkPackage::PDFExport::Export::Cover include WorkPackage::PDFExport::Export::Page include WorkPackage::PDFExport::Export::Style + include ReportingHelper H1_FONT_SIZE = 26 H1_MARGIN_BOTTOM = 2 @@ -351,26 +352,7 @@ def format_hours(hours) end def format_spent_on_time(entry) - start_timestamp = entry.start_timestamp - return "" if start_timestamp.nil? - - result = format_time(start_timestamp, include_date: false) - end_timestamp = entry.end_timestamp - return result if end_timestamp.nil? - - days_between_suffix = format_days_between(start_timestamp, end_timestamp) - "#{result} - #{format_time(end_timestamp, include_date: false)}#{days_between_suffix}" - end - - def format_days_between(start_timestamp, end_timestamp) - days_between = (end_timestamp.to_date - start_timestamp.to_date).to_i - if days_between.positive? - " (+#{days_formatter.format_value(days_between, nil).delete(' ')})" - end - end - - def days_formatter - @days_formatter ||= WorkPackage::Exports::Formatters::Days.new(nil) + spent_on_time_representation(entry.start_timestamp, entry.hours) end def with_times_column? diff --git a/modules/reporting/config/locales/en.yml b/modules/reporting/config/locales/en.yml index 1b56799a2c02..6141e7c3f89f 100644 --- a/modules/reporting/config/locales/en.yml +++ b/modules/reporting/config/locales/en.yml @@ -112,6 +112,8 @@ en: time: "Time" cost_reports: title: "Your Cost Reports XLS export" + start_time: "Start time" + end_time: "End time" reporting: group_by: diff --git a/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb b/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb index 6cdd22eedb73..4606b113ed48 100644 --- a/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb +++ b/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb @@ -52,22 +52,48 @@ def format_columns number_format: currency_format) end + def cost_fields_columns(result) + cost_entry_attributes + .map { |field| show_field field, result.fields[field.to_s] } + end + + def cost_main_times_columns(result) + [ + format_time(result.start_timestamp, include_date: false), + cost_main_end_time_column(result.start_timestamp, result.end_timestamp) + ] + end + + def cost_main_end_time_column(start_timestamp, end_timestamp) + return "" if start_timestamp.nil? || end_timestamp.nil? + + days_between = (end_timestamp.to_date - start_timestamp.to_date).to_i + day_prefix = days_between >= 1 ? "#{end_timestamp.to_date.iso8601} " : "" + "#{day_prefix}#{format_time(end_timestamp, include_date: false)}" + end + + def cost_main_columns(result) + main_cols = [show_field(:spent_on, result.fields[:spent_on.to_s])] + main_cols.concat cost_main_times_columns(result) if with_times_column? + main_cols + end + def cost_row(result) current_cost_type_id = result.fields["cost_type_id"].to_i - cost_entry_attributes - .map { |field| show_field field, result.fields[field.to_s] } - .concat( - [ - show_result(result, current_cost_type_id), # units - cost_type_label(current_cost_type_id, @cost_type), # cost type - show_result(result, 0) # costs/currency - ] + cost_main_columns(result) + .concat(cost_fields_columns(result)) + .push( + + show_result(result, current_cost_type_id), # units + cost_type_label(current_cost_type_id, @cost_type), # cost type + show_result(result, 0) # costs/currency + ) end def build_footer - footer = [""] * cost_entry_attributes.size + footer = [""] * (cost_entry_attributes.size + main_headers.size) footer += if show_result(query, 0) == show_result(query) multiple_unit_types_footer else @@ -84,14 +110,22 @@ def multiple_unit_types_footer ["", "", show_result(query)] end + def main_headers + main = [label_for(:spent_on)] + if with_times_column? + main.push I18n.t(:"export.cost_reports.start_time"), I18n.t(:"export.cost_reports.end_time") + end + main + end + def headers - cost_entry_attributes - .map { |field| label_for(field) } - .concat([CostEntry.human_attribute_name(:units), CostType.model_name.human, CostEntry.human_attribute_name(:costs)]) + main_headers + .concat(cost_entry_attributes.map { |field| label_for(field) }) + .push(CostEntry.human_attribute_name(:units), CostType.model_name.human, CostEntry.human_attribute_name(:costs)) end def cost_entry_attributes - %i[spent_on user_id activity_id work_package_id comments project_id] + %i[user_id activity_id work_package_id comments project_id] end # Returns the results of the query sorted by date the time was spent on and by id @@ -103,4 +137,12 @@ def sorted_results .sort .flat_map { |_, date_results| date_results.sort_by { |r| r.fields["id"] } } end + + def labour_query? + @unit_id == -1 + end + + def with_times_column? + Setting.allow_tracking_start_and_end_times && labour_query? + end end diff --git a/modules/reporting/lib/widget/table/entry_table.rb b/modules/reporting/lib/widget/table/entry_table.rb index 6cab895d47e6..513934a3f0c7 100644 --- a/modules/reporting/lib/widget/table/entry_table.rb +++ b/modules/reporting/lib/widget/table/entry_table.rb @@ -27,7 +27,9 @@ #++ class Widget::Table::EntryTable < Widget::Table - FIELDS = %i[spent_on user_id activity_id work_package_id comments logged_by_id project_id].freeze + include ReportingHelper + + FIELDS = %i[user_id activity_id work_package_id comments logged_by_id project_id].freeze def render content = content_tag :div, class: "generic-table--container -with-footer" do @@ -47,6 +49,7 @@ def render def colgroup content_tag :colgroup do + concat content_tag(:col, "") FIELDS.each do concat content_tag(:col, "") end @@ -56,33 +59,31 @@ def colgroup end end + def head_column_field(field) + head_column(label_for(field)) + end + + def head_column(label) + content_tag(:th) do + content_tag(:div, class: "generic-table--sort-header-outer") do + content_tag(:div, class: "generic-table--sort-header") do + content_tag(:span, label) + end + end + end + end + # rubocop:disable Metrics/AbcSize def head content_tag :thead do content_tag :tr do + concat head_column_field(:spent_on) + concat head_column(I18n.t("label_time")) if with_times_column? FIELDS.map do |field| - concat content_tag(:th) { - content_tag(:div, class: "generic-table--sort-header-outer") do - content_tag(:div, class: "generic-table--sort-header") do - content_tag(:span, label_for(field)) - end - end - } + concat head_column_field(field) end - concat content_tag(:th) { - content_tag(:div, class: "generic-table--sort-header-outer") do - content_tag(:div, class: "generic-table--sort-header") do - content_tag(:span, cost_type.try(:unit_plural) || I18n.t(:units)) - end - end - } - concat content_tag(:th) { - content_tag(:div, class: "generic-table--sort-header-outer") do - content_tag(:div, class: "generic-table--sort-header") do - content_tag(:span, CostEntry.human_attribute_name(:costs)) - end - end - } + concat head_column(cost_type.try(:unit_plural) || I18n.t(:units)) + concat head_column(CostEntry.human_attribute_name(:costs)) hit = false @subject.each_direct_result do |result| next if hit @@ -101,15 +102,16 @@ def head def foot content_tag :tfoot do content_tag :tr do + main_columns = with_times_column? ? 2 : 1 if show_result(@subject, 0) == show_result(@subject) - concat content_tag(:td, "", colspan: FIELDS.size + 1) + concat content_tag(:td, "", colspan: FIELDS.size + main_columns + 1) concat content_tag(:td) { concat content_tag(:div, show_result(@subject), class: "result generic-table--footer-outer") } else - concat content_tag(:td, "", colspan: FIELDS.size) + concat content_tag(:td, "", colspan: FIELDS.size + main_columns) concat content_tag(:td) { concat content_tag(:div, show_result(@subject), @@ -126,15 +128,25 @@ def foot end end + def body_column_field(field, result) + content_tag(:td, show_field(field, result.fields[field.to_s]), + "raw-data": raw_field(field, result.fields[field.to_s]), + class: "left") + end + def body content_tag :tbody do rows = "".html_safe @subject.each_direct_result do |result| rows << (content_tag(:tr) do + concat body_column_field(:spent_on, result) + if with_times_column? + concat content_tag :td, spent_on_time_representation(result.start_timestamp, result["units"].to_f), + class: "start_time right", + "raw-data": result.start_timestamp.to_s + end FIELDS.each do |field| - concat content_tag(:td, show_field(field, result.fields[field.to_s]), - "raw-data": raw_field(field, result.fields[field.to_s]), - class: "left") + concat body_column_field(field, result) end concat content_tag :td, show_result(result, result.fields["cost_type_id"].to_i), class: "units right", @@ -177,4 +189,13 @@ def icons(result) end # rubocop:enable Metrics/AbcSize + + def labour_query? + cost_type_filter = @subject.filters.detect { |f| f.is_a?(CostQuery::Filter::CostTypeId) } + cost_type_filter&.values&.first.to_i == -1 + end + + def with_times_column? + Setting.allow_tracking_start_and_end_times && labour_query? + end end diff --git a/modules/reporting/spec/features/export_cost_report_spec.rb b/modules/reporting/spec/features/export_cost_report_spec.rb index 8301e541d6dd..a687fdd7cc44 100644 --- a/modules/reporting/spec/features/export_cost_report_spec.rb +++ b/modules/reporting/spec/features/export_cost_report_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -29,18 +31,36 @@ require_relative "../spec_helper" require_relative "support/pages/cost_report_page" -RSpec.describe "Cost reports XLS export", :js do +RSpec.describe "Cost reports XLS export", :js, with_flag: { track_start_and_end_times_for_time_entries: true } do shared_let(:project) { create(:project) } shared_let(:user) { create(:admin) } shared_let(:cost_type) { create(:cost_type, name: "Post-war", unit: "cap", unit_plural: "caps") } shared_let(:work_package) { create(:work_package, project:, subject: "Some task") } - shared_let(:cost_entry) { create(:cost_entry, user:, work_package:, project:, cost_type:) } + shared_let(:cost_entry) do + create(:cost_entry, user:, work_package:, project:, + cost_type:, spent_on: 3.days.ago) + end + shared_let(:time_entry) do + create(:time_entry, user:, work_package:, project:, + start_time: 1 * 60, + spent_on: 2.days.ago, + hours: 1.95, + time_zone: "UTC") + end + shared_let(:time_entry_long) do + create(:time_entry, user:, work_package:, project:, + start_time: 1 * 60, + spent_on: 1.day.ago, + hours: 28.0, + time_zone: "UTC") + end + let(:report_page) { Pages::CostReportPage.new project } let(:sheet) { @download_list.refresh_from(page).latest_downloaded_content } # rubocop:disable RSpec/InstanceVariable subject do io = StringIO.new sheet - Spreadsheet.open(io).worksheets.first + Spreadsheet.open(io).worksheets end before do @@ -52,7 +72,70 @@ DownloadList.clear end - it "can download and open the XLS" do + def expect_custom_cost_entry(cost_entry_row) + date, user_ref, _, wp_ref, _, project_ref, costs, type, = cost_entry_row + expect(date).to eq(cost_entry.spent_on.iso8601) + expect(user_ref).to eq(user.name) + expect(wp_ref).to include "Some task" + expect(project_ref).to eq project.name + expect(costs).to eq 1.0 + expect(type).to eq "Post-war" + end + + def expect_labor_cost_entry(cost_entry_row, entry) + date, user_ref, _, wp_ref, _, project_ref, costs, type, = cost_entry_row + expect(date).to eq(entry.spent_on.iso8601) + expect(user_ref).to eq(user.name) + expect(wp_ref).to include "Some task" + expect(project_ref).to eq project.name + expect(costs).to eq entry.hours + expect(type).to eq "Labor" + end + + def expect_time_entry(time_entry_row, entry, start_time_value, end_time_value) + date, start_time, end_time, user_ref, _, wp_ref, _, project_ref, costs, type, = time_entry_row + expect(date).to eq(entry.spent_on.iso8601) + expect(start_time).to eq start_time_value + expect(end_time).to eq end_time_value + expect(user_ref).to eq(user.name) + expect(wp_ref).to include "Some task" + expect(project_ref).to eq project.name + expect(costs).to eq entry.hours + expect(type).to eq "Labor" + end + + def expect_sheet_title(title) + expect(title.first).to include("Cost reports (#{Time.zone.today.strftime('%m/%d/%Y')})") + end + + def expect_cost_entries_sheet + title, _, cost_entry_row, time_entry_row, time_entry_long_row = subject.first.rows + expect_sheet_title title + expect_custom_cost_entry cost_entry_row + expect_labor_cost_entry time_entry_row, time_entry + expect_labor_cost_entry time_entry_long_row, time_entry_long + end + + def expect_time_entries_sheet(allow_show_start_and_end_times) + title, _, time_entry_row, time_entry_long_row = subject.second.rows + expect_sheet_title title + if allow_show_start_and_end_times + expect_time_entry time_entry_row, time_entry, "01:00 AM", "02:57 AM" + expect_time_entry time_entry_long_row, time_entry_long, "01:00 AM", + "#{(time_entry_long.spent_on + 1.day).iso8601} 05:00 AM" + else + expect_labor_cost_entry time_entry_row, time_entry + expect_labor_cost_entry time_entry_long_row, time_entry_long + end + end + + def expect_custom_type_entries_sheet + title, _, cost_entry = subject.third.rows + expect_sheet_title title + expect_custom_cost_entry cost_entry + end + + def export_xls report_page.visit! click_on "Export XLS" @@ -61,16 +144,23 @@ perform_enqueued_jobs expect(page).to have_text(I18n.t("export.succeeded")) + end - title, _, entry, = subject.rows - expect(title.first).to include("Cost reports (#{Time.zone.today.strftime('%m/%d/%Y')})") - date, user_ref, _, wp_ref, _, project_ref, costs, type, = entry + context "with allow_tracking_start_and_end_times", with_settings: { allow_tracking_start_and_end_times: true } do + it "can download and open the XLS" do + export_xls + expect_cost_entries_sheet + expect_time_entries_sheet true + expect_custom_type_entries_sheet + end + end - expect(date).to eq(Time.zone.today.iso8601) - expect(user_ref).to eq(user.name) - expect(wp_ref).to include "Some task" - expect(project_ref).to eq project.name - expect(costs).to eq 1.0 - expect(type).to eq "Post-war" + context "without allow_tracking_start_and_end_times", with_settings: { allow_tracking_start_and_end_times: false } do + it "can download and open the XLS" do + export_xls + expect_cost_entries_sheet + expect_time_entries_sheet false + expect_custom_type_entries_sheet + end end end diff --git a/modules/reporting/spec/features/support/components/cost_reports_base_table.rb b/modules/reporting/spec/features/support/components/cost_reports_base_table.rb index 965aab2b6367..ab386d906d1d 100644 --- a/modules/reporting/spec/features/support/components/cost_reports_base_table.rb +++ b/modules/reporting/spec/features/support/components/cost_reports_base_table.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -58,6 +60,18 @@ def expect_value(value, row) expect(page).to have_css("#{row_selector(row)} .units", text: value) end + def expect_cell_text(value, row, column) + expect(page).to have_css(cell_selector(row, column), text: value) + end + + def expect_sort_header_column(text, present: true) + if present + expect(page).to have_css("#result-table .generic-table--sort-header", text:) + else + expect(page).to have_no_css("#result-table .generic-table--sort-header", text:) + end + end + def edit_time_entry(row, hours:) SeleniumHubWaiter.wait page.find("#{row_selector(row)} .icon-edit").click @@ -82,7 +96,7 @@ def edit_cost_entry(new_value, row, cost_entry_id) SeleniumHubWaiter.wait page.find("#{row_selector(row)} .icon-edit").click - expect(page).to have_current_path("/cost_entries/" + cost_entry_id + "/edit") + expect(page).to have_current_path("/cost_entries/#{cost_entry_id}/edit") SeleniumHubWaiter.wait fill_in("cost_entry_units", with: new_value) @@ -106,5 +120,9 @@ def delete_entry(row) def row_selector(row) "#result-table tbody tr:nth-of-type(#{row})" end + + def cell_selector(row, column) + "#{row_selector(row)} td:nth-of-type(#{column})" + end end end diff --git a/modules/reporting/spec/features/time_entries_spec.rb b/modules/reporting/spec/features/time_entries_spec.rb new file mode 100644 index 000000000000..d7867e33d448 --- /dev/null +++ b/modules/reporting/spec/features/time_entries_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "support/pages/cost_report_page" +require_relative "support/components/cost_reports_base_table" + +RSpec.describe "Cost report showing time entries with start & end times", :js, + with_flag: { track_start_and_end_times_for_time_entries: true } do + shared_let(:project) { create(:project) } + shared_let(:user) { create(:admin) } + shared_let(:work_package) { create(:work_package, project:) } + shared_let(:time_entry) do + create :time_entry, user:, work_package:, project:, + start_time: 1 * 60, + spent_on: 1.day.ago, + hours: 1.25, + time_zone: "UTC" + end + shared_let(:time_entry_long) do + create :time_entry, user:, work_package:, project:, + start_time: 1 * 60, + hours: 28.0, + time_zone: "UTC" + end + let(:report_page) { Pages::CostReportPage.new project } + let(:table) { Components::CostReportsBaseTable.new } + + before do + login_as(user) + report_page.visit! + report_page.clear + report_page.apply + end + + context "with allow_tracking_start_and_end_times", with_settings: { allow_tracking_start_and_end_times: true } do + it "shows the time column" do + table.expect_sort_header_column("TIME", present: true) + table.rows_count 2 + + table.expect_value("1.25 hours", 1) + table.expect_cell_text("01:00 AM - 02:15 AM", 1, 2) + + table.expect_value("28.00 hours", 2) + table.expect_cell_text("01:00 AM - 05:00 AM (+1d)", 2, 2) + end + end + + context "without allow_tracking_start_and_end_times", with_settings: { allow_tracking_start_and_end_times: false } do + it "does not show the time column" do + table.expect_sort_header_column("TIME", present: false) + table.rows_count 2 + end + end +end diff --git a/publiccode.yml b/publiccode.yml new file mode 100644 index 000000000000..fd4d1c2a50c1 --- /dev/null +++ b/publiccode.yml @@ -0,0 +1,159 @@ +# This repository adheres to the publiccode.yml standard by including this +# metadata file that makes public software easily discoverable. +# More info at https://github.com/italia/publiccode.yml + +publiccodeYmlVersion: '0.2' +name: OpenProject +applicationSuite: openDesk +url: 'https://github.com/opf/openproject' +roadmap: 'https://www.openproject.org/roadmap' +releaseDate: '2025-01-21' +softwareVersion: '15.1.1' +developmentStatus: stable +softwareType: standalone/web +platforms: + - web +usedBy: + - ZenDiS GmbH + - Stadt Köln + - Stadt Stuttgart + - Landtag Brandenburg + - Europäische Kommission + - Deutsche Bahn + - FITKO + - Stadt Chemnitz +categories: + - agile-project-management + - budgeting + - enterprise-project-management + - help-desk + - knowledge-management + - project-collaboration + - project-management + - task-management + - time-management + - time-tracking + - workflow-management +dependsOn: + open: + - name: PostgreSQL + versionMin: '13' +maintenance: + type: contract + contacts: + - name: OpenProject GmbH + email: sales@openproject.com + phone: '+4928877707' +legal: + license: LGPL-3.0-or-later + mainCopyrightOwner: OpenProject GmbH + repoOwner: OpenProject GmbH +localisation: + localisationReady: true + availableLanguages: + - de + - fr + - co + - es + - mm + - mn + - nl + - at + - gr + - se + - us +description: + en: + localisedName: OpenProject + documentation: 'https://www.openproject.org/docs' + apiDocumentation: 'https://www.openproject.org/docs/api' + shortDescription: 'OpenProject is a web-based project management software' + screenshots: + - 'screenshots/opencode_openproject_file_storages.png' + - 'screenshots/opencode_openproject_gantt_chart.png' + - 'screenshots/opencode_openproject_gitlab-integration.png' + - 'screenshots/opencode_openproject_homepage.png' + - 'screenshots/opencode_openproject_meetings.png' + - 'screenshots/opencode_openproject_notifications_wp_reminder.png' + - 'screenshots/opencode_openproject_sharepoint_integration_details.png' + - 'screenshots/opencode_openproject_teamplanner.png' + - 'screenshots/opencode_openproject_work_package_detailed_view.png' + features: + - Project planning + - Task management + - Agile workflows + - Meeting management + - Time and Cost tracking + - Collaboration + - Gantt charts + - Agile boards + - Knowledge management + - Detailed reporting + - Customizable workflows + - Custom fields for projects, tasks, and more + - Baseline Comparison + - Integrations into Nextcloud and OneDrive/SharePoint + - Integrations into GitHub and GitLab + longDescription: > + OpenProject is the project management module of + [www.opendesk.eu](http://www.opendesk.eu/), an integrated office and + collaboration suite for the public sector. + + + OpenProject supports project planning, task management, Agile workflows, + time tracking, and collaboration. Key features like Gantt charts, Scrum + and Kanban boards, and detailed reporting make it suitable for a variety + of projects and industries. + + + OpenProject offers both cloud-based and self-hosted deployment options, + giving organizations flexibility in managing data security and operations. + + + The software complies with BSI IT Grundschutz standards for accessibility + and security, making it reliable for industries with strict requirements. + + + Its open-source nature ensures transparency and benefits from an active + community contributing to continuous improvement. + de: + localisedName: OpenProject + documentation: 'https://www.openproject.org/de/docs/' + apiDocumentation: 'https://www.openproject.org/de/docs/api' + shortDescription: 'OpenProject ist die führende Open Source Projektmanagement-Software' + screenshots: + - 'screenshots/opencode_openproject_file_storages.png' + - 'screenshots/opencode_openproject_gantt_chart.png' + - 'screenshots/opencode_openproject_gitlab-integration.png' + - 'screenshots/opencode_openproject_homepage.png' + - 'screenshots/opencode_openproject_meetings.png' + - 'screenshots/opencode_openproject_notifications_wp_reminder.png' + - 'screenshots/opencode_openproject_sharepoint_integration_details.png' + - 'screenshots/opencode_openproject_teamplanner.png' + - 'screenshots/opencode_openproject_work_package_detailed_view.png' + features: + - Projektplanung + - Aufgabenverwaltung + - Agile Workflows + - Besprechungsmanagement + - Zeit- und Kostenverfolgung + - Online-Zusammenarbeit + - Gantt-Diagramme + - Agile Boards + - Wissensmanagement + - Detaillierte Berichterstattung + - Anpassbare Workflows + - Selbstdefinierte Felder für Projekte, Aufgaben und mehr + - Grundlinie (Planungsvergleich) + - Integration in Nextcloud sowie in OneDrive/SharePoint + - Integration in GitHub und in GitLab + longDescription: > + OpenProject ist das Projektmanagement-Modul von [www.opendesk.eu](http://www.opendesk.eu), einer integrierten Office- und Collaboration-Suite für den öffentlichen Sektor. + + OpenProject unterstützt Ihre Projektplanung, Aufgabenmanagement, agile Workflows, Zeiterfassung und teamübergreifende Zusammenarbeit. Dank wichtiger Funktionen wie Gantt-Diagrammen, Scrum- und Kanban-Boards sowie detaillierter Berichterstellung eignet sich das Tool für eine Vielzahl von Projekten und Branchen. + + OpenProject bietet sowohl Cloud-basierte als auch selbst gehostete Bereitstellungsoptionen, wodurch Organisationen flexibel bei der Handhabung von Datensicherheit und -betrieb sind. + + Die Software entspricht den BSI-IT-Grundschutz-Standards für Barrierefreiheit und Sicherheit und ist somit auch für Branchen mit besonders strengen Anforderungen geeignet. + + Die Tatsache, dass es sich um eine Open Source Software handelt, sorgt für Transparenz und bietet den Vorteil einer aktiven Gemeinschaft, welche zur kontinuierlichen Produktverbesserung beiträgt. diff --git a/screenshots/opencode_openproject_file_storages.png b/screenshots/opencode_openproject_file_storages.png new file mode 100644 index 000000000000..0d7276abea58 Binary files /dev/null and b/screenshots/opencode_openproject_file_storages.png differ diff --git a/screenshots/opencode_openproject_gantt_chart.png b/screenshots/opencode_openproject_gantt_chart.png new file mode 100644 index 000000000000..1d7d33a02e8c Binary files /dev/null and b/screenshots/opencode_openproject_gantt_chart.png differ diff --git a/screenshots/opencode_openproject_gitlab-integration.png b/screenshots/opencode_openproject_gitlab-integration.png new file mode 100644 index 000000000000..5bafd25c93a5 Binary files /dev/null and b/screenshots/opencode_openproject_gitlab-integration.png differ diff --git a/screenshots/opencode_openproject_homepage.png b/screenshots/opencode_openproject_homepage.png new file mode 100644 index 000000000000..535bc705eac0 Binary files /dev/null and b/screenshots/opencode_openproject_homepage.png differ diff --git a/screenshots/opencode_openproject_meetings.png b/screenshots/opencode_openproject_meetings.png new file mode 100644 index 000000000000..fa4f49ba030b Binary files /dev/null and b/screenshots/opencode_openproject_meetings.png differ diff --git a/screenshots/opencode_openproject_notifications_wp_reminder.png b/screenshots/opencode_openproject_notifications_wp_reminder.png new file mode 100644 index 000000000000..bf0ed46bb300 Binary files /dev/null and b/screenshots/opencode_openproject_notifications_wp_reminder.png differ diff --git a/screenshots/opencode_openproject_sharepoint_integration_details.png b/screenshots/opencode_openproject_sharepoint_integration_details.png new file mode 100644 index 000000000000..c23b891e67ae Binary files /dev/null and b/screenshots/opencode_openproject_sharepoint_integration_details.png differ diff --git a/screenshots/opencode_openproject_teamplanner.png b/screenshots/opencode_openproject_teamplanner.png new file mode 100644 index 000000000000..c8e5bba4c372 Binary files /dev/null and b/screenshots/opencode_openproject_teamplanner.png differ diff --git a/screenshots/opencode_openproject_work_package_detailed_view.png b/screenshots/opencode_openproject_work_package_detailed_view.png new file mode 100644 index 000000000000..e69f38387195 Binary files /dev/null and b/screenshots/opencode_openproject_work_package_detailed_view.png differ diff --git a/spec/features/support/components/time_logging_modal.rb b/spec/features/support/components/time_logging_modal.rb index 049d61312424..3aeea6992148 100644 --- a/spec/features/support/components/time_logging_modal.rb +++ b/spec/features/support/components/time_logging_modal.rb @@ -38,7 +38,7 @@ class TimeLoggingModal def is_visible(visible) if visible within modal_container do - expect(page).to have_text(I18n.t("button_log_time")) + expect(page).to have_button(I18n.t("button_log_time")) end else expect(page).to have_no_css "dialog#time-entry-dialog" @@ -78,7 +78,7 @@ def has_field_with_value(field, value) def expect_work_package(work_package) title = "#{work_package.type.name} ##{work_package.id} #{work_package.subject}" within modal_container do - expect(page).to have_css("opce-autocompleter[data-resource*=work_packages] .ng-value", text: title, wait: 10) + expect(page).to have_css("opce-time-entries-work-package-autocompleter .ng-value", text: title, wait: 10) end end @@ -124,7 +124,7 @@ def update_time_field(field_name, hour:, minute:) end def update_field(field_name, value) - if field_name.in?(["work_package_id", "user_id", "activity_id"]) + if field_name.in?(%w[work_package_id user_id activity_id]) select_autocomplete modal_container.find("#time_entry_#{field_name}"), query: value, select_text: value, diff --git a/spec/models/changeset_spec.rb b/spec/models/changeset_spec.rb index b307dfd87858..8a31a21be358 100644 --- a/spec/models/changeset_spec.rb +++ b/spec/models/changeset_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -80,7 +82,7 @@ end describe "stripping commit" do - let(:comment) { "This is a looooooooooooooong comment" + (((" " * 80) + "\n") * 5) } + let(:comment) { "This is a looooooooooooooong comment#{"#{' ' * 80}\n" * 5}" } with_virtual_subversion_repository do let(:changeset) do @@ -219,9 +221,11 @@ repository.project.save! end - it "refs keywords any with timelog" do - allow(Setting).to receive(:commit_ref_keywords).and_return "*" - allow(Setting).to receive(:commit_logtime_enabled?).and_return true + it "refs keywords any with timelog" do # rubocop:disable RSpec/ExampleLength + allow(Setting).to receive_messages( + commit_ref_keywords: "*", + commit_logtime_enabled?: true + ) { "2" => 2.0, @@ -259,7 +263,7 @@ expect(time.hours).to eq expected_hours expect(time.spent_on).to eq Date.yesterday - expect(time.activity.is_default).to be true + expect(time.activity).to be_nil expect(time.comments).to include "r520" end end @@ -268,10 +272,12 @@ let!(:work_package2) { create(:work_package, project: repository.project, status: open_status) } it "refs keywords closing with timelog" do - allow(Setting).to receive(:commit_fix_status_id).and_return closed_status.id - allow(Setting).to receive(:commit_ref_keywords).and_return "*" - allow(Setting).to receive(:commit_fix_keywords).and_return "fixes , closes" - allow(Setting).to receive(:commit_logtime_enabled?).and_return true + allow(Setting).to receive_messages( + commit_fix_status_id: closed_status.id, + commit_ref_keywords: "*", + commit_fix_keywords: "fixes , closes", + commit_logtime_enabled?: true + ) c = build(:changeset, repository:,