Merge pull request #10046 from opf/fix/typeahead-query-filters

Make query filters full typeahead with searchable results
pull/10177/head
Oliver Günther 3 years ago committed by GitHub
commit 192367d6e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      app/models/queries/base_query.rb
  2. 7
      app/models/queries/filters/available_filters.rb
  3. 62
      app/models/queries/filters/empty_filter.rb
  4. 1
      app/models/queries/filters/shared/any_user_name_attribute_filter.rb
  5. 4
      app/models/queries/filters/shared/user_name_filter.rb
  6. 1
      app/models/queries/principals.rb
  7. 43
      app/models/queries/principals/filters/typeahead_filter.rb
  8. 1
      app/models/queries/projects.rb
  9. 2
      app/models/queries/projects/filters/name_and_identifier_filter.rb
  10. 43
      app/models/queries/projects/filters/typeahead_filter.rb
  11. 1
      config/locales/js-en.yml
  12. 27
      frontend/src/app/core/apiv3/paths/apiv3-resource.ts
  13. 1
      frontend/src/app/core/global_search/input/global-search-input.component.html
  14. 4
      frontend/src/app/core/global_search/input/global-search-input.component.ts
  15. 2
      frontend/src/app/features/boards/board/board-filter/board-filter.component.html
  16. 5
      frontend/src/app/features/boards/board/inline-add/board-inline-add-autocompleter.component.ts
  17. 1
      frontend/src/app/features/boards/board/inline-add/board-inline-add-autocompleter.html
  18. 4
      frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts
  19. 2
      frontend/src/app/features/team-planner/team-planner/assignee/add-assignee.component.ts
  20. 2
      frontend/src/app/features/work-packages/components/filters/filter-boolean-value/filter-boolean-value.component.ts
  21. 2
      frontend/src/app/features/work-packages/components/filters/filter-container/filter-container.directive.ts
  22. 2
      frontend/src/app/features/work-packages/components/filters/filter-date-time-value/filter-date-time-value.component.ts
  23. 2
      frontend/src/app/features/work-packages/components/filters/filter-date-times-value/filter-date-times-value.component.ts
  24. 2
      frontend/src/app/features/work-packages/components/filters/filter-date-value/filter-date-value.component.ts
  25. 2
      frontend/src/app/features/work-packages/components/filters/filter-dates-value/filter-dates-value.component.ts
  26. 2
      frontend/src/app/features/work-packages/components/filters/filter-integer-value/filter-integer-value.component.ts
  27. 37
      frontend/src/app/features/work-packages/components/filters/filter-searchable-multiselect-value/filter-searchable-multiselect-value.component.html
  28. 139
      frontend/src/app/features/work-packages/components/filters/filter-searchable-multiselect-value/filter-searchable-multiselect-value.component.ts
  29. 2
      frontend/src/app/features/work-packages/components/filters/filter-string-value/filter-string-value.component.ts
  30. 34
      frontend/src/app/features/work-packages/components/filters/filter-toggled-multiselect-value/filter-toggled-multiselect-value.component.html
  31. 104
      frontend/src/app/features/work-packages/components/filters/filter-toggled-multiselect-value/filter-toggled-multiselect-value.component.ts
  32. 47
      frontend/src/app/features/work-packages/components/filters/query-filter/query-filter.component.html
  33. 5
      frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts
  34. 1
      frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.html
  35. 4
      frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.html
  36. 20
      frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html
  37. 97
      frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts
  38. 1
      frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.html
  39. 11
      frontend/src/app/shared/components/autocompleter/te-work-package-autocompleter/te-work-package-autocompleter.component.ts
  40. 3
      frontend/src/global_styles/content/_advanced_filters.sass
  41. 8
      frontend/src/global_styles/content/_autocomplete.sass
  42. 3
      lib/api/decorators/offset_paginated_collection.rb
  43. 2
      modules/dashboards/spec/features/work_package_table_spec.rb
  44. 4
      spec/features/work_packages/table/queries/me_filter_spec.rb
  45. 4
      spec/models/queries/users/filters/any_name_attribute_filter_spec.rb
  46. 2
      spec/support/components/work_packages/filters.rb

@ -163,7 +163,7 @@ class Queries::BaseQuery
end
def context
nil
self
end
def apply_filters(scope)

@ -76,7 +76,12 @@ module Queries
private
def non_existing_filter(key)
::Queries::Filters::NotExistingFilter.create!(name: key)
case key.to_sym
when :typeahead
::Queries::Filters::EmptyFilter.create!(name: key)
else
::Queries::Filters::NotExistingFilter.create!(name: key)
end
end
def get_initialized_filter(key, no_memoization)

@ -0,0 +1,62 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
module Queries
module Filters
class EmptyFilter < Base
def available?
true
end
def type
:inexistent
end
def self.key
:empty
end
def human_name
name.to_s.presence || type
end
# deactivating superclass validation
def validate_inclusion_of_operator; end
def scope
context.default_scope
end
def attributes_hash
nil
end
end
end
end

@ -41,6 +41,7 @@ module Queries::Filters::Shared::AnyUserNameAttributeFilter
def available_operators
[Queries::Operators::Contains,
Queries::Operators::Everywhere,
Queries::Operators::NotContains]
end

@ -49,7 +49,7 @@ module Queries::Filters::Shared::UserNameFilter
["#{sql_concat_name} IN (?)", sql_value]
when '!'
["#{sql_concat_name} NOT IN (?)", sql_value]
when '~'
when '~', '**'
["#{sql_concat_name} LIKE ?", "%#{sql_value}%"]
when '!~'
["#{sql_concat_name} NOT LIKE ?", "%#{sql_value}%"]
@ -62,7 +62,7 @@ module Queries::Filters::Shared::UserNameFilter
case operator
when '=', '!'
values.map { |val| self.class.connection.quote_string(val.downcase) }.join(',')
when '~', '!~'
when '**', '~', '!~'
values.first.downcase
end
end

@ -39,6 +39,7 @@ module Queries::Principals
register.filter query, filters::StatusFilter
register.filter query, filters::NameFilter
register.filter query, filters::AnyNameAttributeFilter
register.filter query, filters::TypeaheadFilter
register.filter query, filters::IdFilter
register.order query, orders::NameOrder

@ -0,0 +1,43 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 Queries::Principals::Filters::TypeaheadFilter < Queries::Principals::Filters::AnyNameAttributeFilter
def self.key
:typeahead
end
def type
:search
end
def human_name
I18n.t('label_search')
end
end

@ -40,6 +40,7 @@ module Queries::Projects
filter query, filters::TemplatedFilter
filter query, filters::PublicFilter
filter query, filters::NameAndIdentifierFilter
filter query, filters::TypeaheadFilter
filter query, filters::CustomFieldFilter
filter query, filters::CreatedAtFilter
filter query, filters::LatestActivityAtFilter

@ -39,7 +39,7 @@ class Queries::Projects::Filters::NameAndIdentifierFilter < Queries::Projects::F
where_equal
when '!'
where_not_equal
when '~'
when '~', '**'
where_contains
when '!~'
where_not_contains

@ -0,0 +1,43 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 Queries::Projects::Filters::TypeaheadFilter < Queries::Projects::Filters::NameAndIdentifierFilter
def self.key
:typeahead
end
def type
:search
end
def human_name
I18n.t('label_search')
end
end

@ -279,6 +279,7 @@ en:
enumeration_doc_categories: "Document categories"
enumeration_work_package_priorities: "Work package priorities"
filter:
more_values_not_shown: "There are %{total} more results, search to filter results."
description:
text_open_filter: "Open this filter with 'ALT' and arrow keys."
text_close_filter: "To select an entry leave the focus for example by pressing enter. To leave without filter select the first (empty) entry."

@ -1,10 +1,15 @@
/* eslint-disable max-classes-per-file */
import { Constructor } from '@angular/cdk/table';
import { SimpleResource, SimpleResourceCollection } from 'core-app/core/apiv3/paths/path-resources';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { Observable } from 'rxjs';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import {
ApiV3Filter,
ApiV3FilterBuilder,
} from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { CollectionResource } from 'core-app/features/hal/resources/collection-resource';
@ -98,7 +103,24 @@ export class ApiV3ResourceCollection<V, T extends ApiV3GettableResource<V>> exte
* @param params additional URL params to append
*/
public filtered<R = ApiV3GettableResource<CollectionResource<V>>>(filters:ApiV3FilterBuilder, params:{ [key:string]:string } = {}, resourceClass?:Constructor<R>):R {
return this.subResource<R>(`?${filters.toParams(params)}`, resourceClass);
const url = new URL(this.path, window.location.origin);
if (url.searchParams.has('filters')) {
const existingFilters = JSON.parse(url.searchParams.get('filters') as string) as ApiV3Filter[];
url.searchParams.set('filters', JSON.stringify(existingFilters.concat(filters.filters)));
} else {
url.searchParams.set('filters', filters.toJson());
}
Object
.keys(params)
.forEach((key) => {
url.searchParams.set(key, params[key]);
});
const cls = resourceClass || ApiV3GettableResource;
// eslint-disable-next-line new-cap
return new cls(this.apiRoot, url.pathname, url.search, this) as R;
}
/**
@ -107,6 +129,7 @@ export class ApiV3ResourceCollection<V, T extends ApiV3GettableResource<V>> exte
* @param segment Additional segment to add to the current path
*/
protected subResource<R = ApiV3GettableResource<HalResource>>(segment:string, cls:Constructor<R> = ApiV3GettableResource as any):R {
// eslint-disable-next-line new-cap
return new cls(this.apiRoot, this.path, segment, this);
}
}

@ -23,7 +23,6 @@
[focusDirectly]="isFocusedDirectly"
[model]="selectedItem"
[searchFn]="customSearchFn"
[loading]="isLoading"
(focus)="onFocus()"
(blur)="onFocusOut()"
(search)="search($event)"

@ -93,8 +93,6 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy {
public markable = false;
public isLoading = false;
getAutocompleterData = (query:string):Observable<any[]> => this.autocompleteWorkPackages(query);
public autocompleterOptions = {
@ -278,14 +276,12 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy {
const hashFreeQuery = this.queryWithoutHash(query);
this.isLoading = true;
return this
.fetchSearchResults(hashFreeQuery, hashFreeQuery !== query)
.get()
.pipe(
map((collection) => this.searchResultsToOptions(collection.elements, hashFreeQuery)),
tap(() => {
this.isLoading = false;
this.setMarkedOption();
}),
);

@ -1 +1 @@
<filter-container></filter-container>
<op-filter-container></op-filter-container>

@ -66,13 +66,9 @@ export class BoardInlineAddAutocompleterComponent implements AfterViewInit {
placeholder: this.I18n.t('js.relations_autocomplete.placeholder'),
};
// Whether we're currently loading
public isLoading = false;
getAutocompleterData = (searchString:string):Observable<WorkPackageResource[]> => {
// Return when the search string is empty
if (searchString.length === 0) {
this.isLoading = false;
return of([]);
}
@ -103,7 +99,6 @@ export class BoardInlineAddAutocompleterComponent implements AfterViewInit {
this.notificationService.handleRawError(error);
return of([]);
}),
tap(() => this.isLoading = false),
);
};

@ -3,7 +3,6 @@
classes="wp-inline-create--reference-autocompleter"
[closeOnSelect]="false"
[placeholder]="text.placeholder"
[loading]="isLoading"
[resource]="autocompleterOptions.resource"
[getOptionsFn]="autocompleterOptions.getOptionsFn"
(close)="cancel()"

@ -102,6 +102,10 @@ export class QueryFilterInstanceSchemaResource extends SchemaResource {
return !!(this.values && this.values.allowedValues);
}
public loadedAllowedValues():boolean {
return Array.isArray(this.values?.allowedValues);
}
public resultingSchema(operator:QueryOperatorResource):QueryFilterInstanceSchemaResource {
const staticSchema = this.$source;
const dependentSchema = this.dependency.forValue(operator.href!.toString());

@ -75,7 +75,7 @@ export class AddAssigneeComponent {
filters.add('member', '=', [this.currentProjectService.id || '']);
if (term) {
filters.add('name_and_identifier', '~', [term]);
filters.add('typeahead', '**', [term]);
}
return this

@ -34,7 +34,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
@Component({
selector: 'filter-boolean-value',
selector: 'op-filter-boolean-value',
templateUrl: './filter-boolean-value.component.html',
})
export class FilterBooleanValueComponent {

@ -47,7 +47,7 @@ import { WorkPackageFiltersService } from 'core-app/features/work-packages/compo
@Component({
templateUrl: './filter-container.directive.html',
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'filter-container',
selector: 'op-filter-container',
})
export class WorkPackageFilterContainerComponent extends UntilDestroyedMixin implements OnInit, OnDestroy {
@Input('showFilterButton') showFilterButton = false;

@ -39,7 +39,7 @@ import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/que
import { AbstractDateTimeValueController } from '../abstract-filter-date-time-value/abstract-filter-date-time-value.controller';
@Component({
selector: 'filter-date-time-value',
selector: 'op-filter-date-time-value',
templateUrl: './filter-date-time-value.component.html',
})
export class FilterDateTimeValueComponent extends AbstractDateTimeValueController implements OnInit {

@ -39,7 +39,7 @@ import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/que
import { AbstractDateTimeValueController } from '../abstract-filter-date-time-value/abstract-filter-date-time-value.controller';
@Component({
selector: 'filter-date-times-value',
selector: 'op-filter-date-times-value',
templateUrl: './filter-date-times-value.component.html',
})
export class FilterDateTimesValueComponent extends AbstractDateTimeValueController implements OnInit {

@ -36,7 +36,7 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
@Component({
selector: 'filter-date-value',
selector: 'op-filter-date-value',
templateUrl: './filter-date-value.component.html',
})
export class FilterDateValueComponent extends UntilDestroyedMixin {

@ -37,7 +37,7 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
@Component({
selector: 'filter-dates-value',
selector: 'op-filter-dates-value',
templateUrl: './filter-dates-value.component.html',
})
export class FilterDatesValueComponent extends UntilDestroyedMixin {

@ -36,7 +36,7 @@ import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/que
import { QueryFilterResource } from 'core-app/features/hal/resources/query-filter-resource';
@Component({
selector: 'filter-integer-value',
selector: 'op-filter-integer-value',
templateUrl: './filter-integer-value.component.html',
})
export class FilterIntegerValueComponent extends UntilDestroyedMixin {

@ -1,25 +1,20 @@
<div class="inline-label ng-select-container"
id="div-values-{{filter.id}}">
<ng-select #ngSelectInstance
[ngModel]="filter.values"
(change)="setValues($event)"
(add)="repositionDropdown()"
(remove)="repositionDropdown()"
(open)="repositionDropdown()"
[compareWith]="compareByHrefOrString"
[clearSearchOnAdd]="true"
[placeholder]="text.placeholder"
[ngClass]="{'-required-highlighting' : isEmpty}"
class="advanced-filters--ng-select -multi-select"
[id]="'values-' + filter.id"
[items]="availableOptions"
[multiple]="true"
[closeOnSelect]="false"
[hideSelected]="true"
appendTo="body"
bindLabel="name"
[typeahead]="requests.input$"
[virtualScroll]="true">
</ng-select>
<op-autocompleter
appendTo="body"
class="advanced-filters--ng-select -multi-select"
[id]="'values-' + filter.id"
[getOptionsFn]="autocompleterFn"
[classes]="{'-required-highlighting' : value.length === 0}"
[virtualScroll]
[closeOnSelect]="false"
[placeholder]="text.placeholder"
[hideSelected]="true"
[multiple]="true"
[fetchDataDirectly]="true"
[focusDirectly]="shouldFocus"
[model]="value"
(change)="setValues($event)"
></op-autocompleter>
</div>

@ -1,127 +1,104 @@
import { NgSelectComponent } from '@ng-select/ng-select';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { DebouncedRequestSwitchmap, errorNotificationHandler } from 'core-app/shared/helpers/rxjs/debounced-input-switchmap';
import { Observable } from 'rxjs';
import {
Observable,
of,
} from 'rxjs';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import { map } from 'rxjs/operators';
import {
map,
switchMap,
withLatestFrom,
} from 'rxjs/operators';
import { ApiV3ResourceCollection } from 'core-app/core/apiv3/paths/apiv3-resource';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { ApiV3Resource } from 'core-app/core/apiv3/cache/cachable-apiv3-resource';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { HalResourceSortingService } from 'core-app/features/hal/services/hal-resource-sorting.service';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import {
AfterViewInit, ChangeDetectionStrategy,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, EventEmitter, Input, NgZone, OnInit, Output, ViewChild,
Component,
EventEmitter,
Input,
NgZone,
Output,
ViewChild,
} from '@angular/core';
import { compareByHrefOrString } from 'core-app/shared/helpers/angular/tracking-functions';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
import { take } from 'rxjs/internal/operators/take';
@Component({
selector: 'filter-searchable-multiselect-value',
selector: 'op-filter-searchable-multiselect-value',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './filter-searchable-multiselect-value.component.html',
})
export class FilterSearchableMultiselectValueComponent extends UntilDestroyedMixin implements OnInit, AfterViewInit {
export class FilterSearchableMultiselectValueComponent extends UntilDestroyedMixin {
@Input() public filter:QueryFilterInstanceResource;
@Input() public shouldFocus = false;
@Output() public filterChanged = new EventEmitter<QueryFilterInstanceResource>();
private _isEmpty:boolean;
public _availableOptions:HalResource[] = [];
public compareByHrefOrString = compareByHrefOrString;
public active:Set<string>;
public requests = new DebouncedRequestSwitchmap<string, HalResource>(
(searchTerm:string) => this.loadAvailable(searchTerm),
errorNotificationHandler(this.halNotification),
true,
private meValue = this.halResourceService.createHalResource(
{
_links: {
self: {
href: this.apiV3Service.users.me.path,
title: this.I18n.t('js.label_me'),
},
},
}, true,
);
autocompleterFn = (searchTerm:string):Observable<HalResource[]> => this.loadAvailable(searchTerm);
readonly text = {
placeholder: this.I18n.t('js.placeholders.selection'),
};
public get value() {
public get value():string[]|HalResource[] {
return this.filter.values;
}
public get availableOptions() {
return this._availableOptions;
}
public set availableOptions(val:HalResource[]) {
this._availableOptions = this.halSorting.sort(val);
}
public get isEmpty():boolean {
return this._isEmpty = this.value.length === 0;
}
@ViewChild('ngSelectInstance', { static: true }) ngSelectInstance:NgSelectComponent;
constructor(readonly halResourceService:HalResourceService,
readonly halSorting:HalResourceSortingService,
readonly apiV3Service:ApiV3Service,
readonly cdRef:ChangeDetectorRef,
readonly I18n:I18nService,
protected currentProject:CurrentProjectService,
protected currentUser:CurrentUserService,
readonly halNotification:HalResourceNotificationService,
readonly ngZone:NgZone) {
super();
}
ngOnInit() {
this.initialization();
// Request an empty value to load warning early on
this.requests.input$.next('');
}
ngAfterViewInit():void {
if (this.ngSelectInstance && this.shouldFocus) {
this.ngSelectInstance.focus();
}
}
initialization() {
this
.requests
.output$.pipe(
this.untilDestroyed(),
)
.subscribe((values:HalResource[]) => {
this.availableOptions = values;
this.cdRef.detectChanges();
});
}
public loadAvailable(matching:string):Observable<HalResource[]> {
const filters:ApiV3FilterBuilder = this.createFilters(matching);
const { href } = this.filter.currentSchema!.values!.allowedValues as any;
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const { href } = this.filter.currentSchema!.values!.allowedValues as { href:string };
const filteredData = (this.apiV3Service.collectionFromString(href) as
ApiV3ResourceCollection<HalResource, ApiV3Resource>)
.filtered(filters)
.filtered(filters, { pageSize: '-1' })
.get()
.pipe(map((collection) => collection.elements));
.pipe(
switchMap((collection) => this.withMeValue(matching, collection.elements)),
);
return filteredData;
}
protected createFilters(matching:string) {
protected createFilters(matching:string):ApiV3FilterBuilder {
const filters = new ApiV3FilterBuilder();
if (matching) {
filters.add('subjectOrId', '**', [matching]);
filters.add('typeahead', '**', [matching]);
}
return filters;
@ -130,20 +107,32 @@ export class FilterSearchableMultiselectValueComponent extends UntilDestroyedMix
public setValues(val:any) {
this.filter.values = val.length > 0 ? (Array.isArray(val) ? val : [val]) : [] as HalResource[];
this.filterChanged.emit(this.filter);
this.requests.input$.next('');
this.cdRef.detectChanges();
}
public repositionDropdown() {
if (this.ngSelectInstance) {
const component = (this.ngSelectInstance) as any;
if (component && component.dropdownPanel) {
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
component.dropdownPanel._updatePosition();
}, 25);
});
}
private withMeValue(matching:string, elements:HalResource[]):Observable<HalResource[]> {
if (!this.isUserResource || (!!matching && matching !== 'me')) {
return of(elements);
}
return this
.currentUser
.isLoggedIn$
.pipe(
take(1),
withLatestFrom(this.currentUser.user$),
map(([logged, user]) => {
if (logged && user) {
return [this.meValue].concat(elements);
}
return elements;
}),
);
}
private get isUserResource() {
const type = _.get(this.filter.currentSchema, 'values.type', null) as string;
return type && type.indexOf('User') > 0;
}
}

@ -35,7 +35,7 @@ import { componentDestroyed } from '@w11k/ngx-componentdestroyed';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
@Component({
selector: 'filter-string-value',
selector: 'op-filter-string-value',
templateUrl: './filter-string-value.component.html',
})
export class FilterStringValueComponent extends UntilDestroyedMixin {

@ -1,24 +1,18 @@
<div class="inline-label ng-select-container"
id="div-values-{{filter.id}}">
<ng-select #ngSelectInstance
[ngModel]="filter.values"
(change)="setValues($event)"
(add)="repositionDropdown()"
(remove)="repositionDropdown()"
(open)="repositionDropdown()"
[compareWith]="compareByHrefOrString"
[clearSearchOnAdd]="true"
[placeholder]="text.placeholder"
[ngClass]="{'-required-highlighting' : isEmpty}"
class="advanced-filters--ng-select -multi-select"
[id]="'values-' + filter.id"
[items]="availableOptions"
[multiple]="true"
[closeOnSelect]="false"
[hideSelected]="true"
appendTo="body"
bindLabel="name"
[virtualScroll]="true">
</ng-select>
<op-autocompleter
appendTo="body"
class="advanced-filters--ng-select -multi-select"
[id]="'values-' + filter.id"
[items]="availableOptions"
[classes]="{'-required-highlighting' : value.length === 0}"
[virtualScroll]
[closeOnSelect]="false"
[placeholder]="text.placeholder"
[hideSelected]="true"
[multiple]="true"
[model]="value"
(change)="setValues($event)"
></op-autocompleter>
</div>

@ -27,7 +27,6 @@
//++
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { UserResource } from 'core-app/features/hal/resources/user-resource';
import {
AfterViewInit,
ChangeDetectionStrategy,
@ -40,19 +39,16 @@ import {
ViewChild,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { compareByHrefOrString } from 'core-app/shared/helpers/angular/tracking-functions';
import { HalResourceSortingService } from 'core-app/features/hal/services/hal-resource-sorting.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { NgSelectComponent } from '@ng-select/ng-select';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
import { RootResource } from 'core-app/features/hal/resources/root-resource';
import { CollectionResource } from 'core-app/features/hal/resources/collection-resource';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
@Component({
selector: 'filter-toggled-multiselect-value',
selector: 'op-filter-toggled-multiselect-value',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './filter-toggled-multiselect-value.component.html',
})
@ -65,11 +61,7 @@ export class FilterToggledMultiselectValueComponent implements OnInit, AfterView
@ViewChild('ngSelectInstance', { static: true }) ngSelectInstance:NgSelectComponent;
public _availableOptions:HalResource[] = [];
public compareByHrefOrString = compareByHrefOrString;
private _isEmpty:boolean;
public availableOptions:HalResource[] = [];
readonly text = {
placeholder: this.I18n.t('js.placeholders.selection'),
@ -84,8 +76,10 @@ export class FilterToggledMultiselectValueComponent implements OnInit, AfterView
readonly I18n:I18nService) {
}
ngOnInit() {
this.fetchAllowedValues();
ngOnInit():void {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const values = (this.filter.currentSchema!.values!.allowedValues as HalResource[]);
this.availableOptions = this.halSorting.sort(values);
}
ngAfterViewInit():void {
@ -94,93 +88,13 @@ export class FilterToggledMultiselectValueComponent implements OnInit, AfterView
}
}
public get value() {
public get value():unknown[] {
return this.filter.values;
}
public setValues(val:any) {
this.filter.values = _.castArray(val);
public setValues(val:HalResource[]|string[]|string|HalResource):void {
this.filter.values = _.castArray(val) as HalResource[]|string[];
this.filterChanged.emit(this.filter);
this.cdRef.detectChanges();
}
public get availableOptions() {
return this._availableOptions;
}
public set availableOptions(val:HalResource[]) {
this._availableOptions = this.halSorting.sort(val);
}
public get isEmpty():boolean {
return this._isEmpty = this.value.length === 0;
}
public repositionDropdown() {
if (this.ngSelectInstance) {
setTimeout(() => {
const component = (this.ngSelectInstance) as any;
if (component && component.dropdownPanel) {
component.dropdownPanel._updatePosition();
}
}, 25);
}
}
private get isUserResource() {
const type = _.get(this.filter.currentSchema, 'values.type', null);
return type && type.indexOf('User') > 0;
}
private fetchAllowedValues() {
if ((this.filter.currentSchema!.values!.allowedValues as CollectionResource).$load) {
this.loadAllowedValues();
} else {
this.availableOptions = (this.filter.currentSchema!.values!.allowedValues as HalResource[]);
}
}
private loadAllowedValues() {
const valuesSchema = this.filter.currentSchema!.values!;
const loadingPromises = [(valuesSchema.allowedValues as any).$load()];
// If it is a User resource, we want to have the 'me' option.
// We therefore fetch the current user from the api and copy
// the current user's value from the set of allowedValues. The
// copy will have it's name altered to 'me' and will then be
// prepended to the list.
if (this.isUserResource) {
loadingPromises.push(this.apiV3Service.root.get().toPromise());
}
Promise.all(loadingPromises)
.then(((resources:Array<HalResource>) => {
const options = (resources[0] as CollectionResource).elements;
this.availableOptions = options;
if (this.isUserResource && this.filter.filter.id !== 'memberOfGroup') {
this.addMeValue((resources[1] as RootResource).user);
}
}));
}
private addMeValue(currentUser:UserResource) {
if (!(currentUser && currentUser.href)) {
return;
}
const me:HalResource = this.halResourceService.createHalResource(
{
_links: {
self: {
href: this.apiV3Service.users.me.path,
title: this.I18n.t('js.label_me'),
},
},
}, true,
);
this._availableOptions.unshift(me);
}
}

@ -38,78 +38,79 @@
</option>
</select>
<filter-boolean-value
<op-filter-boolean-value
*ngIf="valueType == '[1]Boolean'"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-boolean-value>
></op-filter-boolean-value>
</div>
<!-- Values -->
<ng-container *ngIf="showValuesInput && valueType">
<div class="advanced-filters--filter-value" [ngSwitch]="valueType">
<filter-integer-value
<op-filter-integer-value
*ngSwitchCase="'[1]Integer'"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-integer-value>
></op-filter-integer-value>
<filter-date-value
<op-filter-date-value
*ngSwitchCase="'[1]Date'"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-date-value>
></op-filter-date-value>
<filter-dates-value
<op-filter-dates-value
*ngSwitchCase="'[2]Date'"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-dates-value>
></op-filter-dates-value>
<filter-date-time-value
<op-filter-date-time-value
*ngSwitchCase="'[1]DateTime'"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-date-time-value>
></op-filter-date-time-value>
<filter-date-times-value
<op-filter-date-times-value
*ngSwitchCase="'[2]DateTime'"
[shouldFocus]="shouldFocus"
(filterChanged)="onFilterUpdated($event)"
[filter]="filter"
></filter-date-times-value>
></op-filter-date-times-value>
<filter-string-value
<op-filter-string-value
*ngSwitchCase="'[1]String'"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-string-value>
></op-filter-string-value>
<filter-string-value
<op-filter-string-value
*ngSwitchCase="'[1]Float'"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-string-value>
></op-filter-string-value>
<filter-searchable-multiselect-value
*ngSwitchCase="'[]WorkPackage'"
<ng-container *ngSwitchDefault>
<op-filter-toggled-multiselect-value
*ngIf="filter.currentSchema.loadedAllowedValues()"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-searchable-multiselect-value>
<filter-toggled-multiselect-value
*ngSwitchDefault
></op-filter-toggled-multiselect-value>
<op-filter-searchable-multiselect-value
*ngIf="!filter.currentSchema.loadedAllowedValues()"
(filterChanged)="onFilterUpdated($event)"
[shouldFocus]="shouldFocus"
[filter]="filter"
></filter-toggled-multiselect-value>
></op-filter-searchable-multiselect-value>
</ng-container>
</div>
</ng-container>

@ -84,13 +84,9 @@ export class WorkPackageRelationsAutocompleteComponent {
@Output() onEmptySelected = new EventEmitter<undefined>();
// Whether we're currently loading
public isLoading = false;
getAutocompleterData = (query:string|null):Observable<HalResource[]> => {
// Return when the search string is empty
if (query === null || query.length === 0) {
this.isLoading = false;
return of([]);
}
@ -107,7 +103,6 @@ export class WorkPackageRelationsAutocompleteComponent {
this.notificationService.handleRawError(error);
return of([]);
}),
tap(() => this.isLoading = false),
);
};

@ -4,7 +4,6 @@
[appendTo]="appendToContainer"
[placeholder]="inputPlaceholder"
[typeToSearchText]="inputPlaceholder"
[loading]="isLoading"
[resource]="autocompleterOptions.resource"
[defaultData]="false"
(open)="onOpen()"

@ -7,11 +7,11 @@
<ng-container *ngIf="renderTable">
<!-- Filter container (if requested) -->
<filter-container *ngIf="configuration.withFilters"
<op-filter-container *ngIf="configuration.withFilters"
[showFilterButton]="configuration.showFilterButton"
[filterButtonText]="configuration.filterButtonText">
</filter-container>
</op-filter-container>
<!-- TABLE + TIMELINE horizontal split -->
<wp-table *ngIf="!configuration.isCardView"

@ -10,7 +10,7 @@
[hideSelected]="hideSelected"
[appendTo]="appendTo"
[multiple]="multiple"
[loading]="isLoading"
[loading]="loading$ | async"
[addTag]="addTag"
[virtualScroll]="virtualScroll"
[required]="required"
@ -81,11 +81,12 @@
let-item="item"
let-index="index"
let-search="searchTerm"
let-clear="clear"
*ngIf="labelRequired"
>
<ng-container
[ngTemplateOutlet]="labelTemplate ? labelTemplate : defaultLabel"
[ngTemplateOutletContext]="{$implicit:item, search:search, index:index }"
[ngTemplateOutlet]="labelTemplate || defaultLabel"
[ngTemplateOutletContext]="{$implicit:item, search:search, index:index, clear:clear }"
></ng-container>
</ng-template>
@ -174,19 +175,22 @@
</div>
</ng-container>
<ng-container *ngSwitchCase="resource ==='subproject' || resource ==='version' || resource ==='status' || resource ==='default'">
<ng-container *ngSwitchCase="resource ==='subproject' || resource ==='version' || resource ==='status' || resource ==='default' || !resource">
<span [ngOptionHighlight]="search">{{ item.name }}</span>
</ng-container>
</ng-template>
<ng-template let-item let-search="search" #defaultLabel [ngSwitch]="resource">
<ng-container *ngSwitchCase="'work_packages'">
<ng-template let-item let-search="search" let-clear="clear" #defaultLabel>
<ng-container *ngIf="resource === 'work_packages'">
<span [ngOptionHighlight]="search">
{{item.type?.name }} #{{ item.id }} {{ item.subject }}
</span>
</ng-container>
<ng-container *ngSwitchDefault>
<span [ngOptionHighlight]="search">{{ item.name }}</span>
<ng-container *ngIf="resource !== 'work_packages'">
<span class="ng-value-icon left" (click)="clear(item)">×</span>
<span
[ngOptionHighlight]="search"
class="ng-value-label">{{item.name}}</span>
</ng-container>
</ng-template>

@ -1,28 +1,40 @@
/* We just forward the ng-select outputs without renaming */
/* eslint-disable @angular-eslint/no-output-native */
import {
AfterViewInit,
OnChanges,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild,
EventEmitter,
HostBinding,
Input,
NgZone,
OnChanges,
Output,
SimpleChanges,
TemplateRef,
ViewChild,
SimpleChanges,
HostBinding,
} from '@angular/core';
import { DropdownPosition, NgSelectComponent } from '@ng-select/ng-select';
import {
Observable,
DropdownPosition,
NgSelectComponent,
} from '@ng-select/ng-select';
import {
BehaviorSubject,
merge,
NEVER,
Observable,
of,
Subject,
merge,
} from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import {
debounceTime,
distinctUntilChanged,
filter,
switchMap,
tap,
} from 'rxjs/operators';
import { GroupValueFn } from '@ng-select/ng-select/lib/ng-select.component';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
@ -85,7 +97,7 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
@Input() public items?:IOPAutocompleterOption[]|HalResource[];
private items$ = new Subject();
private items$ = new BehaviorSubject(null);
@Input() public clearSearchOnAdd?:boolean = true;
@ -101,15 +113,15 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
@Input() public markFirst ? = true;
@Input() public placeholder?:string = this.I18n.t('js.autocompleter.placeholder');
@Input() public placeholder:string = this.I18n.t('js.autocompleter.placeholder');
@Input() public notFoundText?:string = this.I18n.t('js.autocompleter.notFoundText');
@Input() public notFoundText:string = this.I18n.t('js.autocompleter.notFoundText');
@Input() public typeToSearchText?:string = this.I18n.t('js.autocompleter.typeToSearchText');
@Input() public typeToSearchText:string = this.I18n.t('js.autocompleter.typeToSearchText');
@Input() public addTagText?:string;
@Input() public loadingText?:string;
@Input() public loadingText:string = this.I18n.t('js.ajax.loading');
@Input() public clearAllText?:string;
@ -119,8 +131,6 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
@Input() public appendTo?:string;
@Input() public loading?:boolean = false;
@Input() public closeOnSelect?:boolean = true;
@Input() public hideSelected?:boolean = false;
@ -131,7 +141,7 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
@Input() public maxSelectedItems?:number;
@Input() public groupBy?:string | Function;
@Input() public groupBy?:string|(() => string);
@Input() public groupValue?:GroupValueFn;
@ -163,28 +173,28 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
@Input() public editableSearchTerm?:boolean = false;
@Input() public keyDownFn ? = (_:KeyboardEvent) => true;
@Input() public keyDownFn ? = ():boolean => true;
@Input() public typeahead:Subject<string> = new Subject();
@Input() public typeahead:BehaviorSubject<string|null> = new BehaviorSubject(null);
// a function for setting the options of ng-select
@Input() public getOptionsFn:(searchTerm:string) => any;
@Input() public getOptionsFn:(searchTerm:string) => Observable<unknown>;
@Output() public open = new EventEmitter<any>();
@Output() public open = new EventEmitter<unknown>();
@Output() public close = new EventEmitter<any>();
@Output() public close = new EventEmitter<unknown>();
@Output() public change = new EventEmitter<any>();
@Output() public change = new EventEmitter<unknown>();
@Output() public focus = new EventEmitter<any>();
@Output() public focus = new EventEmitter<unknown>();
@Output() public blur = new EventEmitter<any>();
@Output() public blur = new EventEmitter<unknown>();
@Output() public search = new EventEmitter<{ term:string, items:any[] }>();
@Output() public search = new EventEmitter<{ term:string, items:unknown[] }>();
@Output() public keydown = new EventEmitter<any>();
@Output() public keydown = new EventEmitter<unknown>();
@Output() public clear = new EventEmitter<any>();
@Output() public clear = new EventEmitter<unknown>();
@Output() public add = new EventEmitter();
@ -198,9 +208,9 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
public active:Set<string>;
public results$:any;
public results$:Observable<unknown>;
public isLoading = false;
public loading$ = new Subject<boolean>();
@ViewChild('ngSelectInstance') ngSelectInstance:NgSelectComponent;
@ -225,7 +235,7 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
super();
}
ngOnChanges(changes:SimpleChanges) {
ngOnChanges(changes:SimpleChanges):void {
if (changes.items) {
this.items$.next(changes.items.currentValue);
}
@ -239,24 +249,35 @@ export class OpAutocompleterComponent extends UntilDestroyedMixin implements Aft
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
this.results$ = merge(
(this.items$ || new Subject()),
this.items$,
this.typeahead.pipe(
filter(() => !!(this.defaultData || this.getOptionsFn)),
filter((val) => val !== null),
distinctUntilChanged(),
debounceTime(250),
(this.defaultData
? switchMap((queryString) => this.opAutocompleterService.loadData(queryString, this.resource, this.filters, this.searchKey))
: this.getOptionsFn
? switchMap((queryString) => this.getOptionsFn(queryString))
: switchMap(() => NEVER)
tap(() => this.loading$.next(true)),
switchMap((queryString:string) => {
if (this.defaultData) {
return this.opAutocompleterService.loadData(queryString, this.resource, this.filters, this.searchKey);
}
if (this.getOptionsFn) {
return this.getOptionsFn(queryString);
}
return NEVER;
}),
tap(
() => this.loading$.next(false),
() => this.loading$.next(false),
),
),
);
if (this.fetchDataDirectly) {
this.results$ = this.defaultData
? (this.opAutocompleterService.loadData('', this.resource, this.filters, this.searchKey))
: (this.getOptionsFn(''));
this.typeahead.next('');
}
if (this.openDirectly) {
this.ngSelectInstance.open();
this.ngSelectInstance.focus();

@ -12,7 +12,6 @@
[typeahead]="typeahead"
[appendTo]="appendTo"
[hideSelected]="hideSelected"
[loading]="finishedLoading | async"
[id]="id"
(change)="changeModel($event)"
(open)="opened()"

@ -27,10 +27,13 @@
//++
import {
AfterViewInit, Component, EventEmitter, Injector, Output, ViewEncapsulation,
AfterViewInit,
Component,
EventEmitter,
Injector,
Output,
ViewEncapsulation,
} from '@angular/core';
import { of } from 'rxjs';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { WorkPackageAutocompleterComponent } from 'core-app/shared/components/autocompleter/work-package-autocompleter/wp-autocompleter.component';
export type TimeEntryWorkPackageAutocompleterMode = 'all'|'recent';
@ -55,8 +58,6 @@ export class TimeEntryWorkPackageAutocompleterComponent extends WorkPackageAutoc
this.text.recent = this.I18n.t('js.label_recent');
}
public loading = false;
public mode:TimeEntryWorkPackageAutocompleterMode = 'all';
public setMode(value:TimeEntryWorkPackageAutocompleterMode) {

@ -96,6 +96,9 @@ $advanced-filters--grid-gap: 10px
.advanced-filters--select
@extend .form--select
.advanced-filters--ng-select
width: 100%
.advanced-filters--affix
@extend .form--field-affix
@include form--field-affix-mixin--transparent

@ -183,6 +183,12 @@ mark.ui-autocomplete-match
.ng-dropdown-panel
z-index: 9500 !important
// Overrides for the ng-footer-tmp option
.ng-footer-text
font-size: 0.9rem
padding: 5px
font-style: italic
.ng-option-label
vertical-align: top
@ -190,7 +196,7 @@ mark.ui-autocomplete-match
font-size: 14px
line-height: 22px
.op-avatar
.op-avatar
margin-right: 8px
.work-package-table--container .ng-dropdown-panel

@ -41,7 +41,8 @@ module API
@self_link_base = self_link
@query = query
@page = page.to_i > 0 ? page.to_i : 1
@per_page = resulting_page_size(per_page, models)
resolved_page_size = resolve_page_size(per_page)
@per_page = resulting_page_size(resolved_page_size, models)
full_self_link = make_page_link(page: @page, page_size: @per_page)
paged = paged_models(models)

@ -102,7 +102,7 @@ describe 'Arbitrary WorkPackage query table widget dashboard', type: :feature, j
# At the beginning, the default query is displayed
expect(filter_area.area)
.to have_selector('.subject', text: type_work_package.subject)
.to have_selector('.subject', text: type_work_package.subject, wait: 10)
expect(filter_area.area)
.to have_selector('.subject', text: other_type_work_package.subject)

@ -114,6 +114,10 @@ describe 'filter me value', js: true do
# Expect new work packages receive assignee
split_screen = wp_table.create_wp_by_button wp_user.type
# Wait a bit for the page to load
sleep 2
subject = split_screen.edit_field :subject
subject.set_value 'foobar'
subject.submit_by_enter

@ -50,7 +50,9 @@ describe Queries::Users::Filters::AnyNameAttributeFilter, type: :model do
describe '#available_operators' do
it 'supports = and !' do
expect(instance.available_operators)
.to eql [Queries::Operators::Contains, Queries::Operators::NotContains]
.to contain_exactly Queries::Operators::Contains,
Queries::Operators::NotContains,
Queries::Operators::Everywhere
end
end
end

@ -170,7 +170,7 @@ module Components
Array(value).each do |val|
select_autocomplete page.find("#filter_#{id}"),
query: val,
results_selector: '.advanced-filters--ng-select .ng-dropdown-panel-items'
results_selector: '.ng-dropdown-panel-items'
end
else
within_values(id) do

Loading…
Cancel
Save