Quickly filter by text within normal filters section without showing any operator (#6960)

* Add quick filter by text

* Debounce search filter input

[ci skip]
pull/6977/head
Wieland Lindenthal 6 years ago committed by Oliver Günther
parent 5d8836e30c
commit 4970471739
  1. 5
      app/assets/stylesheets/content/_advanced_filters.sass
  2. 2
      config/locales/js-en.yml
  3. 16
      frontend/src/app/components/filters/query-filters/query-filters.component.html
  4. 3
      frontend/src/app/components/filters/query-filters/query-filters.component.ts
  5. 111
      frontend/src/app/components/filters/quick-filter-by-text-input/quick-filter-by-text-input.component.ts
  6. 5
      frontend/src/app/components/filters/quick-filter-by-text-input/quick-filter-by-text-input.html
  7. 2
      frontend/src/app/components/wp-buttons/wp-filter-button/wp-filter-button.component.ts
  8. 4
      frontend/src/app/components/wp-fast-table/state/wp-table-filters.service.ts
  9. 13
      frontend/src/app/components/wp-fast-table/wp-table-filters.ts
  10. 4
      frontend/src/app/modules/hal/resources/query-filter-instance-resource.ts
  11. 2
      frontend/src/app/modules/work_packages/openproject-work-packages.module.ts
  12. 4
      modules/my_project_page/spec/features/block_editing_spec.rb
  13. 4
      spec/features/types/form_configuration_query_spec.rb
  14. 4
      spec/features/work_packages/table/configuration_modal/filter_spec.rb
  15. 4
      spec/features/wysiwyg/macros/embedded_tables_spec.rb

@ -55,6 +55,8 @@
grid-gap: 10px grid-gap: 10px
align-items: center align-items: center
margin-bottom: 10px margin-bottom: 10px
&.--without-operator
grid-template-columns: 20% 45% 50px
.advanced-filters--filter-name, .advanced-filters--filter-name,
.advanced-filters--add-filter-label .advanced-filters--add-filter-label
@ -111,14 +113,13 @@
.advanced-filters--spacer .advanced-filters--spacer
border-top: 1px solid $filters--border-color border-top: 1px solid $filters--border-color
height: 1px height: 1px
margin: 0.75rem 1rem margin: 0.75rem 0
.advanced-filters--spacer, .advanced-filters--spacer,
.advanced-filters--filter .advanced-filters--filter
&.hidden &.hidden
display: none !important display: none !important
fieldset#date-range p fieldset#date-range p
margin: 2px 0 2px 0 margin: 2px 0 2px 0

@ -591,6 +591,7 @@ en:
label_enable_multi_select: "Enable multiselect" label_enable_multi_select: "Enable multiselect"
label_disable_multi_select: "Disable multiselect" label_disable_multi_select: "Disable multiselect"
label_filter_add: "Add filter" label_filter_add: "Add filter"
label_filter_by_text: "Filter by text"
label_options: "Options" label_options: "Options"
label_column_multiselect: "Combined dropdown field: Select with arrow keys, confirm selection with enter, delete with backspace" label_column_multiselect: "Combined dropdown field: Select with arrow keys, confirm selection with enter, delete with backspace"
label_switch_to_single_select: "Switch to single select" label_switch_to_single_select: "Switch to single select"
@ -601,6 +602,7 @@ en:
message_view_spent_time: "Show spent time for this work package" message_view_spent_time: "Show spent time for this work package"
message_work_package_read_only: "Work package is locked in this status. No attribute other than status can be altered." message_work_package_read_only: "Work package is locked in this status. No attribute other than status can be altered."
no_value: "No value" no_value: "No value"
placeholder_filter_by_text: "Subject, description, comments, ..."
inline_create: inline_create:
title: 'Click here to add a new work package to this list' title: 'Click here to add a new work package to this list'
create: create:

@ -8,6 +8,22 @@
</a> </a>
<ul class="advanced-filters--filters"> <ul class="advanced-filters--filters">
<li id="filter_search"
class="advanced-filters--filter --without-operator">
<label for="filter-by-text-input"
class="advanced-filters--filter-name"
[textContent]="text.filter_by_text"
[attr.title]="text.filter_by_text">
</label>
<div class="advanced-filters--filter-value">
<wp-filter-by-text-input>
</wp-filter-by-text-input>
</div>
</li>
<li class="advanced-filters--spacer" *ngIf="filters.anyCurrentlyVisibleFilters"></li>
<ng-container *ngFor="let filter of filters.current; trackBy: trackByName ; let index = index"> <ng-container *ngFor="let filter of filters.current; trackBy: trackByName ; let index = index">
<li id="filter_{{filter.id}}" <li id="filter_{{filter.id}}"
query-filter query-filter

@ -66,7 +66,8 @@ export class QueryFiltersComponent implements OnInit, OnChanges, OnDestroy {
close_form: this.I18n.t('js.close_form_title'), close_form: this.I18n.t('js.close_form_title'),
selected_filter_list: this.I18n.t('js.label_selected_filter_list'), selected_filter_list: this.I18n.t('js.label_selected_filter_list'),
button_delete: this.I18n.t('js.button_delete'), button_delete: this.I18n.t('js.button_delete'),
please_select: this.I18n.t('js.placeholders.selection') please_select: this.I18n.t('js.placeholders.selection'),
filter_by_text: this.I18n.t('js.work_packages.label_filter_by_text')
}; };
constructor(readonly wpTableFilters:WorkPackageTableFiltersService, constructor(readonly wpTableFilters:WorkPackageTableFiltersService,

@ -0,0 +1,111 @@
// -- 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 {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {I18nService} from "app/modules/common/i18n/i18n.service";
import {WorkPackageTableFiltersService} from "app/components/wp-fast-table/state/wp-table-filters.service";
import {QueryFilterResource} from "app/modules/hal/resources/query-filter-resource";
import {componentDestroyed, untilComponentDestroyed} from "ng2-rx-componentdestroyed";
import {TableState} from "app/components/wp-table/table-state/table-state";
import {WorkPackageCacheService} from "app/components/work-packages/work-package-cache.service";
import {Subject} from "rxjs";
import {debounceTime, distinctUntilChanged} from "rxjs/operators";
@Component({
selector: 'wp-filter-by-text-input',
templateUrl: './quick-filter-by-text-input.html'
})
export class WorkPackageFilterByTextInputComponent implements OnInit, OnDestroy {
public text = {
createWithDropdown: this.I18n.t('js.work_packages.create.button'),
createButton: this.I18n.t('js.label_work_package'),
explanation: this.I18n.t('js.label_create_work_package'),
placeholder: this.I18n.t('js.work_packages.placeholder_filter_by_text')
};
public searchTerm:string;
private searchTermChanged:Subject<string> = new Subject<string>();
private availableSearchFilter:QueryFilterResource;
constructor(readonly I18n:I18nService,
readonly tableState:TableState,
readonly wpTableFilters:WorkPackageTableFiltersService,
readonly wpCacheService:WorkPackageCacheService) {
this.searchTermChanged
.pipe(
untilComponentDestroyed(this),
debounceTime(250),
distinctUntilChanged()
)
.subscribe(term => {
this.searchTerm = term;
let currentSearchFilter = this.wpTableFilters.find('search');
if (this.searchTerm.length > 0) {
if (!currentSearchFilter) {
currentSearchFilter = this.wpTableFilters.currentState.add(this.availableSearchFilter);
}
currentSearchFilter.operator = currentSearchFilter.findOperator('**')!;
currentSearchFilter.values = [this.searchTerm];
} else if (currentSearchFilter) {
this.wpTableFilters.currentState.remove(currentSearchFilter);
}
this.wpTableFilters.replace(this.wpTableFilters.currentState);
});
}
public ngOnInit() {
let self:WorkPackageFilterByTextInputComponent = this;
this.wpTableFilters
.observeUntil(
componentDestroyed(this)
)
.subscribe(() => {
const currentSearchFilter = this.wpTableFilters.find('search');
if (currentSearchFilter) {
this.searchTerm = currentSearchFilter.values[0] as string;
} else {
this.searchTerm = '';
}
self.availableSearchFilter = _.find(self.wpTableFilters.currentState.availableFilters,
{ id: 'search' }) as QueryFilterResource;
});
}
public ngOnDestroy() {
// Nothing to do
}
public valueChange(term:string) {
this.searchTermChanged.next(term);
}
}

@ -0,0 +1,5 @@
<input id="filter-by-text-input"
type="text"
placeholder="{{text.placeholder}}"
[ngModel]="searchTerm"
(ngModelChange)="valueChange($event)">

@ -90,7 +90,7 @@ export class WorkPackageFilterButtonComponent extends AbstractWorkPackageButtonC
this.wpTableFilters this.wpTableFilters
.observeUntil(componentDestroyed(this)) .observeUntil(componentDestroyed(this))
.subscribe(state => { .subscribe(state => {
this.count = state.currentVisibleFilters.length; this.count = state.currentlyVisibleFilters.length;
this.initialized = true; this.initialized = true;
}); });
} }

@ -71,6 +71,10 @@ export class WorkPackageTableFiltersService extends WorkPackageTableBaseService<
); );
} }
public find(id:string):QueryFilterInstanceResource|undefined {
return _.find(this.currentState.current, filter => filter.id === id);
}
public applyToQuery(query:QueryResource) { public applyToQuery(query:QueryResource) {
query.filters = this.current; query.filters = this.current;
return true; return true;

@ -50,6 +50,7 @@ export class WorkPackageTableFilters extends WorkPackageTableBaseState<QueryFilt
'includes', 'includes',
'requires', 'requires',
'required', 'required',
'search',
'subjectOrId' 'subjectOrId'
]; ];
@ -97,9 +98,15 @@ export class WorkPackageTableFilters extends WorkPackageTableBaseState<QueryFilt
return _.every(this.current, filter => filter.isCompletelyDefined()); return _.every(this.current, filter => filter.isCompletelyDefined());
} }
public get currentVisibleFilters() { public get currentlyVisibleFilters() {
return this.currentFilters const invisibleFilters = new Set(this.hidden);
.filter((filter) => this.hidden.indexOf(filter.id) === -1); invisibleFilters.delete('search');
return _.reject(this.currentFilters, (filter) => invisibleFilters.has(filter.id));
}
public get anyCurrentlyVisibleFilters():boolean {
return this.currentlyVisibleFilters.length > 0;
} }
private get currentFilters() { private get currentFilters() {

@ -77,4 +77,8 @@ export class QueryFilterInstanceResource extends HalResource {
public isCompletelyDefined() { public isCompletelyDefined() {
return this.values.length || (this.currentSchema && !this.currentSchema.isValueRequired()); return this.values.length || (this.currentSchema && !this.currentSchema.isValueRequired());
} }
public findOperator(operatorSymbol:string):QueryOperatorResource|undefined {
return _.find(this.schema.availableOperators, (operator:QueryOperatorResource) => operator.id === operatorSymbol) as QueryOperatorResource|undefined;
}
} }

@ -186,6 +186,7 @@ import {WorkPackageSplitViewComponent} from "core-app/modules/work_packages/rout
import {WorkPackagesFullViewComponent} from "core-app/modules/work_packages/routing/wp-full-view/wp-full-view.component"; import {WorkPackagesFullViewComponent} from "core-app/modules/work_packages/routing/wp-full-view/wp-full-view.component";
import {AttachmentsUploadComponent} from 'core-app/modules/attachments/attachments-upload/attachments-upload.component'; import {AttachmentsUploadComponent} from 'core-app/modules/attachments/attachments-upload/attachments-upload.component';
import {AttachmentListComponent} from 'core-app/modules/attachments/attachment-list/attachment-list.component'; import {AttachmentListComponent} from 'core-app/modules/attachments/attachment-list/attachment-list.component';
import {WorkPackageFilterByTextInputComponent} from "core-components/filters/quick-filter-by-text-input/quick-filter-by-text-input.component";
@NgModule({ @NgModule({
imports: [ imports: [
@ -342,6 +343,7 @@ import {AttachmentListComponent} from 'core-app/modules/attachments/attachment-l
WorkPackageTimelineTableController, WorkPackageTimelineTableController,
WorkPackageCreateButtonComponent, WorkPackageCreateButtonComponent,
WorkPackageFilterByTextInputComponent,
// Single view // Single view
WorkPackageOverviewTabComponent, WorkPackageOverviewTabComponent,

@ -215,7 +215,7 @@ describe 'My project page editing', type: :feature, js: true do
modal.expect_open modal.expect_open
modal.switch_to 'Filters' modal.switch_to 'Filters'
filters.expect_filter_count 1 filters.expect_filter_count 2
filters.add_filter_by('Type', 'is', work_package.type.name) filters.add_filter_by('Type', 'is', work_package.type.name)
modal.switch_to 'Columns' modal.switch_to 'Columns'
@ -254,7 +254,7 @@ describe 'My project page editing', type: :feature, js: true do
modal.expect_open modal.expect_open
modal.switch_to 'Filters' modal.switch_to 'Filters'
filters.expect_filter_count 2 filters.expect_filter_count 3
modal.switch_to 'Columns' modal.switch_to 'Columns'
columns.assume_opened columns.assume_opened
columns.expect_checked 'ID' columns.expect_checked 'ID'

@ -198,7 +198,7 @@ describe 'form query configuration', type: :feature, js: true do
modal.expect_open modal.expect_open
modal.switch_to 'Filters' modal.switch_to 'Filters'
# the templated filter should be hidden in the Filters tab # the templated filter should be hidden in the Filters tab
filters.expect_filter_count 0 filters.expect_filter_count 1
filters.add_filter_by('Type', 'is', type_task.name) filters.add_filter_by('Type', 'is', type_task.name)
filters.save filters.save
@ -225,7 +225,7 @@ describe 'form query configuration', type: :feature, js: true do
# Expect filter still there # Expect filter still there
modal.expect_open modal.expect_open
modal.switch_to 'Filters' modal.switch_to 'Filters'
filters.expect_filter_count 1 filters.expect_filter_count 2
filters.expect_filter_by 'Type', 'is', type_task.name filters.expect_filter_by 'Type', 'is', type_task.name
# Remove the filter again # Remove the filter again

@ -38,7 +38,7 @@ describe 'Work Package table configuration modal filters spec', js: true do
wp_table.expect_work_package_listed work_package_with_version, work_package_without_version wp_table.expect_work_package_listed work_package_with_version, work_package_without_version
filters.open filters.open
filters.expect_filter_count 1 filters.expect_filter_count 2
filters.add_filter_by('Version', 'is', version.name) filters.add_filter_by('Version', 'is', version.name)
filters.save filters.save
@ -48,7 +48,7 @@ describe 'Work Package table configuration modal filters spec', js: true do
wp_table.save_as('Some query name') wp_table.save_as('Some query name')
filters.open filters.open
filters.expect_filter_count 2 filters.expect_filter_count 3
filters.remove_filter 'version' filters.remove_filter 'version'
filters.save filters.save

@ -55,7 +55,7 @@ describe 'Wysiwyg embedded work package tables',
modal.expect_open modal.expect_open
modal.switch_to 'Filters' modal.switch_to 'Filters'
filters.expect_filter_count 1 filters.expect_filter_count 2
filters.add_filter_by('Type', 'is', work_package.type.name) filters.add_filter_by('Type', 'is', work_package.type.name)
modal.switch_to 'Columns' modal.switch_to 'Columns'
@ -80,7 +80,7 @@ describe 'Wysiwyg embedded work package tables',
modal.expect_open modal.expect_open
modal.switch_to 'Filters' modal.switch_to 'Filters'
filters.expect_filter_count 2 filters.expect_filter_count 3
modal.switch_to 'Columns' modal.switch_to 'Columns'
columns.assume_opened columns.assume_opened
columns.expect_checked 'ID' columns.expect_checked 'ID'

Loading…
Cancel
Save