diff --git a/app/assets/stylesheets/content/_tabs.lsg b/app/assets/stylesheets/content/_tabs.lsg index ae839fe282..2f8367f0dc 100644 --- a/app/assets/stylesheets/content/_tabs.lsg +++ b/app/assets/stylesheets/content/_tabs.lsg @@ -22,3 +22,28 @@ ``` + +The height of the tabs section can be reduced applying the `-narrow` modification class. + +``` +
+ + + +
+``` diff --git a/app/assets/stylesheets/content/_tabs.sass b/app/assets/stylesheets/content/_tabs.sass index c2a0305653..ef53440d93 100644 --- a/app/assets/stylesheets/content/_tabs.sass +++ b/app/assets/stylesheets/content/_tabs.sass @@ -74,6 +74,7 @@ content-tabs height: 40px border-bottom: 1px solid #DDDDDD margin-bottom: 1rem + .tabrow display: block overflow-x: auto @@ -85,6 +86,18 @@ content-tabs padding-right: 1rem font-size: 14px + &.-narrow + margin-bottom: 0 + height: initial + + .tabrow + height: initial + line-height: initial + + li + line-height: initial + padding-bottom: 6px + .scrollable-tabs--button display: block width: 20px diff --git a/app/models/queries/time_entries/orders/default_order.rb b/app/models/queries/time_entries/orders/default_order.rb index 17639a482f..3727f145d5 100644 --- a/app/models/queries/time_entries/orders/default_order.rb +++ b/app/models/queries/time_entries/orders/default_order.rb @@ -32,6 +32,6 @@ class Queries::TimeEntries::Orders::DefaultOrder < Queries::BaseOrder self.model = TimeEntry def self.key - /\A(id|hours|spent_on|created_on)\z/ + /\A(id|hours|spent_on|created_on|updated_on)\z/ end end diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index e22cdb74ca..65180b20f3 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -356,6 +356,7 @@ en: label_project_plural: "Projects" label_visibility_settings: "Visibility settings" label_quote_comment: "Quote this comment" + label_recent: "Recent" label_reset: "Reset" label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" diff --git a/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts b/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts index 3da5b80aa8..357d16dc49 100644 --- a/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts +++ b/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts @@ -66,7 +66,7 @@ export class CreateAutocompleterComponent implements AfterViewInit { @ViewChild('ngSelectComponent', {static: false}) public ngSelectComponent:NgSelectComponent; - public text:any = { + public text:{ [key:string]:string } = { add_new_action: this.I18n.t('js.label_create'), }; diff --git a/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.html b/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.html new file mode 100644 index 0000000000..47485fdee7 --- /dev/null +++ b/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.html @@ -0,0 +1,43 @@ + + +
+ +
+
+ + : {{search}} + + +
{{ item.name }}
+
+
diff --git a/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.sass b/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.sass new file mode 100644 index 0000000000..7e1acc00ca --- /dev/null +++ b/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.sass @@ -0,0 +1,2 @@ +.ng-dropdown-panel .ng-dropdown-header + padding-bottom: 0 \ No newline at end of file diff --git a/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.ts b/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.ts new file mode 100644 index 0000000000..129bfe1ad5 --- /dev/null +++ b/frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.ts @@ -0,0 +1,72 @@ +// -- 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, + ViewEncapsulation, + Output, + EventEmitter, + ChangeDetectorRef, +} from '@angular/core'; +import {WorkPackageAutocompleterComponent} from "core-app/modules/common/autocomplete/wp-autocompleter.component"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; + +export type TimeEntryWorkPackageAutocompleterMode = 'all'|'recent'; + +@Component({ + templateUrl: './te-work-package-autocompleter.component.html', + styleUrls: ['./te-work-package-autocompleter.component.sass'], + selector: 'te-work-package-autocompleter', + encapsulation: ViewEncapsulation.None +}) +export class TimeEntryWorkPackageAutocompleterComponent extends WorkPackageAutocompleterComponent implements AfterViewInit { + @Output() modeSwitch = new EventEmitter(); + + constructor(readonly I18n:I18nService, + readonly cdRef:ChangeDetectorRef, + readonly currentProject:CurrentProjectService, + readonly pathHelper:PathHelperService) { + super(I18n, cdRef, currentProject, pathHelper); + + this.text['all'] = this.I18n.t('js.label_all'); + this.text['recent'] = this.I18n.t('js.label_recent'); + } + + public loading:boolean = false; + 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/modules/common/openproject-common.module.ts b/frontend/src/app/modules/common/openproject-common.module.ts index 986f55f973..8622f7a892 100644 --- a/frontend/src/app/modules/common/openproject-common.module.ts +++ b/frontend/src/app/modules/common/openproject-common.module.ts @@ -99,6 +99,7 @@ import {CurrentProjectService} from "core-components/projects/current-project.se 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"; +import {TimeEntryWorkPackageAutocompleterComponent} from "core-app/modules/common/autocomplete/te-work-package-autocompleter.component"; export function bootstrapModule(injector:Injector) { return () => { @@ -147,6 +148,7 @@ export function bootstrapModule(injector:Injector) { DynamicModule.withComponents([ VersionAutocompleterComponent, WorkPackageAutocompleterComponent, + TimeEntryWorkPackageAutocompleterComponent, CreateAutocompleterComponent]), ], exports: [ @@ -281,6 +283,7 @@ export function bootstrapModule(injector:Injector) { CreateAutocompleterComponent, VersionAutocompleterComponent, WorkPackageAutocompleterComponent, + TimeEntryWorkPackageAutocompleterComponent, HomescreenNewFeaturesBlockComponent, BoardVideoTeaserModalComponent diff --git a/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts b/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts index badaf7784e..0ab0385a5d 100644 --- a/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts +++ b/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts @@ -35,7 +35,7 @@ import {untilComponentDestroyed} from "ng2-rx-componentdestroyed"; 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 { tap, map } from 'rxjs/operators'; import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service"; export interface ValueOption { @@ -54,16 +54,16 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn public valueOptions:ValueOption[]; protected valuesLoaded = false; - public text:{ requiredPlaceholder:string, placeholder:string }; + public text:{ [key:string]:string }; public appendTo:any = null; private hiddenOverflowContainer = '.__hidden_overflow_container'; public halSorting:HalResourceSortingService; - private _autocompleterComponent:CreateAutocompleterComponent; + protected _autocompleterComponent:CreateAutocompleterComponent; - public referenceOutputs = { + public referenceOutputs:{ [key:string]:Function } = { onCreate: (newElement:HalResource) => this.onCreate(newElement), onChange: (value:HalResource) => this.onChange(value), onKeydown: (event:JQuery.TriggeredEvent) => this.handler.handleUserKeydown(event, true), @@ -128,7 +128,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn } private setValues(availableValues:HalResource[]) { - this.availableOptions = this.halSorting.sort(availableValues); + this.availableOptions = this.sortValues(availableValues); this.addEmptyOption(); this.valueOptions = this.availableOptions.map(el => this.mapAllowedValue(el)); } @@ -150,7 +150,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn protected loadValuesFromBackend(query?:string) { return from( - this.schema.allowedValues.$link.$fetch(this.allowedValuesFilter(query)) as Promise + this.allowedValuesFetch(query) ).pipe( tap(collection => { // if it is an unpaginated collection or if we get all possible entries when fetching with a blank @@ -171,6 +171,10 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn ); } + protected allowedValuesFetch(query?:string) { + return this.schema.allowedValues.$link.$fetch(this.allowedValuesFilter(query)) as Promise; + } + private addValue(val:HalResource) { this.availableOptions.push(val); this.valueOptions.push({name: val.name, $href: val.$href}); @@ -236,6 +240,10 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn return this.schema.required; } + protected sortValues(availableValues:HalResource[]) { + return this.halSorting.sort(availableValues); + } + protected mapAllowedValue(value:HalResource):ValueOption { return {name: value.name, $href: value.$href}; } diff --git a/frontend/src/app/modules/fields/edit/field-types/te-work-package-edit-field.component.ts b/frontend/src/app/modules/fields/edit/field-types/te-work-package-edit-field.component.ts index 2dad6e6631..e61b3b3e44 100644 --- a/frontend/src/app/modules/fields/edit/field-types/te-work-package-edit-field.component.ts +++ b/frontend/src/app/modules/fields/edit/field-types/te-work-package-edit-field.component.ts @@ -28,11 +28,44 @@ import {Component} from "@angular/core"; import {WorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/work-package-edit-field.component"; +import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service"; +import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder"; +import { + TimeEntryWorkPackageAutocompleterComponent, + TimeEntryWorkPackageAutocompleterMode +} from "core-app/modules/common/autocomplete/te-work-package-autocompleter.component"; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; + +const RECENT_TIME_ENTRIES_MAGIC_NUMBER = 30; @Component({ templateUrl: './work-package-edit-field.component.html' }) export class TimeEntryWorkPackageEditFieldComponent extends WorkPackageEditFieldComponent { + public timeEntryDm = this.injector.get(TimeEntryDmService); + 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) => { + let 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. @@ -41,4 +74,55 @@ export class TimeEntryWorkPackageEditFieldComponent extends WorkPackageEditField 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 allowedValuesFetch(query?:string) { + if (!this.recentWorkPackageIds) { + return this + .timeEntryDm + .list({ filters: [['user_id', '=', ['me']]], sortBy: [["updated_on", "desc"]], pageSize: RECENT_TIME_ENTRIES_MAGIC_NUMBER }) + .then(collection => { + this.recentWorkPackageIds = collection + .elements + .map((timeEntry) => timeEntry.workPackage.idFromLink) + .filter((v, i, a) => a.indexOf(v) === i); + + return super.allowedValuesFetch(query); + }); + } else { + return super.allowedValuesFetch(query); + } + } + + protected allowedValuesFilter(query?:string):{} { + let filters:ApiV3FilterBuilder = new ApiV3FilterBuilder(); + + if ((this._autocompleterComponent as TimeEntryWorkPackageAutocompleterComponent).mode === 'recent') { + 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); + } else { + return super.sortValues(availableValues); + } + } + + protected sortValuesByRecentIds(availableValues:HalResource[]) { + return availableValues + .sort((a, b) => { + return this.recentWorkPackageIds.indexOf(a.id!) - this.recentWorkPackageIds.indexOf(b.id!); + }); + } } diff --git a/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html b/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html index 01ddef287d..a21fbe18be 100644 --- a/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html +++ b/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html @@ -6,7 +6,7 @@ disabled: inFlight, typeahead: requests.input$, id: handler.htmlId, - finishedLoading: true, + finishedLoading: requests.loading$, hideSelected: true, classes: 'inline-edit--field ' + handler.fieldName }" [ndcDynamicOutputs]="referenceOutputs"> diff --git a/modules/my_page/spec/features/my/time_entries_current_user_spec.rb b/modules/my_page/spec/features/my/time_entries_current_user_spec.rb index 82a99ca2cc..2cea38f0f4 100644 --- a/modules/my_page/spec/features/my/time_entries_current_user_spec.rb +++ b/modules/my_page/spec/features/my/time_entries_current_user_spec.rb @@ -175,14 +175,15 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr spent_on_field.expect_value((Date.today.beginning_of_week(:sunday) + 3.days).strftime) + expect(page) + .not_to have_selector('.ng-spinner-loader') + wp_field.input_element.click wp_field.set_value(other_work_package.subject) expect(page) .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 @@ -221,6 +222,11 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr activity_field.set_value(other_activity.name) wp_field.input_element.click + # As the other_work_package now has time logged, it is now considered to be a + # recent work package. + within('.ng-dropdown-header') do + click_link(I18n.t('js.label_recent')) + end wp_field.set_value(other_work_package.subject) hours_field.set_value('6')