Merge pull request #8031 from opf/feature/timelog_on_widget_recent

recent work packages tab on time entry autocompleter

[ci skip]
pull/8041/head
Oliver Günther 5 years ago committed by GitHub
commit 51b0c1cf0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 25
      app/assets/stylesheets/content/_tabs.lsg
  2. 13
      app/assets/stylesheets/content/_tabs.sass
  3. 2
      app/models/queries/time_entries/orders/default_order.rb
  4. 1
      config/locales/js-en.yml
  5. 2
      frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts
  6. 43
      frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.html
  7. 2
      frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.sass
  8. 72
      frontend/src/app/modules/common/autocomplete/te-work-package-autocompleter.component.ts
  9. 3
      frontend/src/app/modules/common/openproject-common.module.ts
  10. 20
      frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts
  11. 84
      frontend/src/app/modules/fields/edit/field-types/te-work-package-edit-field.component.ts
  12. 2
      frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html
  13. 10
      modules/my_page/spec/features/my/time_entries_current_user_spec.rb

@ -22,3 +22,28 @@
</div>
</div>
```
The height of the tabs section can be reduced applying the `-narrow` modification class.
```
<div class="scrollable-tabs -narrow">
<ul class="tabrow"">
<li class="selected"
tab-id="tab_1">
<a href="#scrollable-tabs?tab=tab_1">Tab 1</a>
</li>
<li tab-id="tab_2">
<a href="#scrollable-tabs?tab=tab_2">Tab 2</a>
</li>
<li tab-id="tab_3">
<a href="#scrollable-tabs?tab=tab_3">Tab 3</a>
</li>
</ul>
<div hidden="true" class="scrollable-tabs--button -left">
<span class="icon-arrow-left2"></span>
</div>
<div hidden="true" class="scrollable-tabs--button -right">
<span class="icon-arrow-right2"></span>
</div>
</div>
```

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

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

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

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

@ -0,0 +1,43 @@
<ng-select #ngSelectComponent
[(ngModel)]="model"
[items]="availableValues"
[ngClass]="classes"
[addTag]="createAllowed"
[virtualScroll]="true"
[required]="required"
[clearable]="!required"
[disabled]="disabled"
[typeahead]="typeahead"
[clearOnBackspace]="false"
[appendTo]="appendTo"
[hideSelected]="hideSelected"
[loading]="finishedLoading | async"
[id]="id"
(change)="changeModel($event)"
(open)="opened()"
(close)="closed()"
(keydown)="keyPressed($event)"
bindLabel="name">
<ng-template ng-header-tmp>
<div class="scrollable-tabs -narrow">
<ul class="tabrow">
<li [ngClass]="{'selected': mode === 'all'}"
(click)="setMode('all')">
<a href="#" [textContent]="text.all">
</a>
</li>
<li [ngClass]="{'selected': mode === 'recent'}"
(click)="setMode('recent')">
<a href="#" [textContent]="text.recent">
</a>
</li>
</ul>
</div>
</ng-template>
<ng-template ng-tag-tmp let-search="searchTerm">
<b [textContent]="text.add_new_action"></b>: {{search}}
</ng-template>
<ng-template ng-option-tmp let-item="item" let-search="searchTerm">
<div [ngOptionHighlight]="search" class="ng-option-label ellipsis">{{ item.name }}</div>
</ng-template>
</ng-select>

@ -0,0 +1,2 @@
.ng-dropdown-panel .ng-dropdown-header
padding-bottom: 0

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

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

@ -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<CollectionResource>
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<CollectionResource>;
}
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};
}

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

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

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

Loading…
Cancel
Save