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')