From 01316bad7bb0d69ea38975838697a4187b4512c3 Mon Sep 17 00:00:00 2001 From: Sahil Budhwar Date: Fri, 14 Dec 2018 23:41:32 +0530 Subject: [PATCH] feat(query): Query Builder (#2806) * fix(query): string enclouser added to query values - an optional quotation enclosure is added for the values in query - area.name: "new area" and area.name: new area are same * feat(query): query suggestion service added * feat(query): query suggestion added to the component * fix(query): iteration added to the suggestion * fix(query): remove suggestions on blur * fix(query): sugestion replace function added * fix(query): add dropdown for suggestions * fix(query): add delay to query suggestion * fix(query): suggestion on click replace the text added * fix(query): dont close dropdown on blur * fix(query): fix replace suggestions * fix(query): add css for dropdown-item * fix(query): new fields added to the suggestion * fix(angular/cdk): add angular cdk to package.json * fix(list): add dropdown list component for Highlight interface from cdk * fix(query): add a11y using ActiveDescendantKeyManager on suggestion dropdown * fix(query): fixed unit test for query * fix(query): add support for left and right arrow keys to suggest query fields * fix(less): generic class fixed * fix(tests): fix failing functional tests * fix(query): fix `expression change error`, focus on input after pressing enter * fix(query): remove unused imports and refactor code * fix(query): clear the activeItem from dropdown on Enter * chore(refactor): use constants from query-keys instead of public instances from filterService --- package-lock.json | 11 +- package.json | 1 + .../group-types-panel.component.ts | 9 +- .../iteration-list-entry.component.ts | 17 +- .../iterations-panel.component.ts | 17 +- .../labels/labels.component.ts | 6 +- .../planner-board/planner-board.component.ts | 5 +- .../planner-list/planner-list.component.ts | 17 +- .../planner-query.component.html | 43 +++- .../planner-query.component.less | 4 +- .../planner-query/planner-query.component.ts | 142 +++++++++-- .../planner-query/planner-query.module.ts | 8 +- .../toolbar-panel/toolbar-panel.component.ts | 5 +- src/app/effects/work-item-utils.ts | 9 +- src/app/effects/work-item.effects.ts | 6 +- src/app/models/area.model.ts | 10 + src/app/models/board.model.ts | 9 +- src/app/models/iteration.model.ts | 14 ++ src/app/models/label.model.ts | 6 + src/app/models/work-item.spec.ts | 3 +- src/app/services/filter.service.spec.ts | 68 +++-- src/app/services/filter.service.ts | 203 +++++++++------ src/app/services/query-keys.ts | 12 + .../services/query-suggestion.service.spec.ts | 238 ++++++++++++++++++ src/app/services/query-suggestion.service.ts | 182 ++++++++++++++ .../list-item/list-item.component.html | 5 + .../list-item/list-item.component.less | 16 ++ .../widgets/list-item/list-item.component.ts | 26 ++ src/app/widgets/list-item/list-item.module.ts | 9 + src/tests/specs/agileTemplate.spec.ts | 2 +- 30 files changed, 929 insertions(+), 174 deletions(-) create mode 100644 src/app/services/query-keys.ts create mode 100644 src/app/services/query-suggestion.service.spec.ts create mode 100644 src/app/services/query-suggestion.service.ts create mode 100644 src/app/widgets/list-item/list-item.component.html create mode 100644 src/app/widgets/list-item/list-item.component.less create mode 100644 src/app/widgets/list-item/list-item.component.ts create mode 100644 src/app/widgets/list-item/list-item.module.ts diff --git a/package-lock.json b/package-lock.json index cc09646ce..d1842b85c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,14 @@ "tslib": "^1.9.0" } }, + "@angular/cdk": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-6.4.7.tgz", + "integrity": "sha512-18x0U66fLD5kGQWZ9n3nb75xQouXlWs7kUDaTd8HTrHpT1s2QIAqlLd1KxfrYiVhsEC2jPQaoiae7VnBlcvkBg==", + "requires": { + "tslib": "1.9.3" + } + }, "@angular/common": { "version": "6.1.6", "resolved": "https://registry.npmjs.org/@angular/common/-/common-6.1.6.tgz", @@ -18990,8 +18998,7 @@ "tslib": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", - "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", - "dev": true + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==" }, "tslint": { "version": "5.8.0", diff --git a/package.json b/package.json index 049f89a81..932fae31f 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "npm": ">= 5.3.0" }, "dependencies": { + "@angular/cdk": "^6.4.7", "lodash": "^4.17.4", "moment": "^2.18.1", "mydatepicker": "^2.1.0", diff --git a/src/app/components_ngrx/group-types-panel/group-types-panel.component.ts b/src/app/components_ngrx/group-types-panel/group-types-panel.component.ts index 62d5922ca..e78570dfa 100644 --- a/src/app/components_ngrx/group-types-panel/group-types-panel.component.ts +++ b/src/app/components_ngrx/group-types-panel/group-types-panel.component.ts @@ -9,6 +9,7 @@ import { AuthenticationService } from 'ngx-login-client'; import { GroupTypeQuery, GroupTypeUI } from '../../models/group-types.model'; import { FilterService } from '../../services/filter.service'; +import { AND, EQUAL } from '../../services/query-keys'; // ngrx stuff import { select, Store } from '@ngrx/store'; @@ -76,10 +77,10 @@ export class GroupTypesComponent implements OnInit, OnDestroy { fnBuildQueryParam(witGroup) { //Query for work item type group const type_query = this.filterService.queryBuilder( - 'typegroup.name', this.filterService.equal_notation, witGroup.name + 'typegroup.name', EQUAL, witGroup.name ); const first_join = this.filterService.queryJoiner( - {}, this.filterService.and_notation, type_query + {}, AND, type_query ); //second_join gives json object return this.filterService.jsonToQuery(first_join); @@ -88,11 +89,11 @@ export class GroupTypesComponent implements OnInit, OnDestroy { fnBuildQueryParamForBoard(witGroup) { const type_query = this.filterService.queryBuilder( - 'boardContextId', this.filterService.equal_notation, witGroup.id + 'boardContextId', EQUAL, witGroup.id ); // join query with typeQuery const second_join = this.filterService.queryJoiner( - {}, this.filterService.and_notation, type_query + {}, AND, type_query ); return this.filterService.jsonToQuery(second_join); } diff --git a/src/app/components_ngrx/iteration-list-entry/iteration-list-entry.component.ts b/src/app/components_ngrx/iteration-list-entry/iteration-list-entry.component.ts index 6b1105acd..0f3890162 100644 --- a/src/app/components_ngrx/iteration-list-entry/iteration-list-entry.component.ts +++ b/src/app/components_ngrx/iteration-list-entry/iteration-list-entry.component.ts @@ -13,6 +13,7 @@ import { import { AuthenticationService } from 'ngx-login-client'; +import { AND, EQUAL } from '../../services/query-keys'; import { GroupTypeUI } from './../../models/group-types.model'; import { IterationUI } from './../../models/iteration.model'; import { FilterService } from './../../services/filter.service'; @@ -52,12 +53,12 @@ export class IterationListEntryComponent implements OnInit { constructURL(iterationId: string) { //Query for work item type group - const type_query = this.filterService.queryBuilder('typegroup.name', this.filterService.equal_notation, this.witGroup.name); + const type_query = this.filterService.queryBuilder('typegroup.name', EQUAL, this.witGroup.name); //Query for iteration - const iteration_query = this.filterService.queryBuilder('iteration', this.filterService.equal_notation, iterationId); + const iteration_query = this.filterService.queryBuilder('iteration', EQUAL, iterationId); //Join type and space query - const first_join = this.filterService.queryJoiner({}, this.filterService.and_notation, type_query); - const second_join = this.filterService.queryJoiner(first_join, this.filterService.and_notation, iteration_query); + const first_join = this.filterService.queryJoiner({}, AND, type_query); + const second_join = this.filterService.queryJoiner(first_join, AND, iteration_query); //this.setGroupType(witGroup); //second_join gives json object return this.filterService.jsonToQuery(second_join); @@ -65,12 +66,12 @@ export class IterationListEntryComponent implements OnInit { constructURLforBoard(iterationId: string) { //Query for work item type group - const type_query = this.filterService.queryBuilder('boardContextId', this.filterService.equal_notation, this.witGroup.id); + const type_query = this.filterService.queryBuilder('boardContextId', EQUAL, this.witGroup.id); //Query for iteration - const iteration_query = this.filterService.queryBuilder('iteration', this.filterService.equal_notation, iterationId); + const iteration_query = this.filterService.queryBuilder('iteration', EQUAL, iterationId); // join type and iteration query - const first_join = this.filterService.queryJoiner({}, this.filterService.and_notation, type_query); - const second_join = this.filterService.queryJoiner(first_join, this.filterService.and_notation, iteration_query); + const first_join = this.filterService.queryJoiner({}, AND, type_query); + const second_join = this.filterService.queryJoiner(first_join, AND, iteration_query); return this.filterService.jsonToQuery(second_join); } diff --git a/src/app/components_ngrx/iterations-panel/iterations-panel.component.ts b/src/app/components_ngrx/iterations-panel/iterations-panel.component.ts index 1dfa72b4f..bbcd184bb 100644 --- a/src/app/components_ngrx/iterations-panel/iterations-panel.component.ts +++ b/src/app/components_ngrx/iterations-panel/iterations-panel.component.ts @@ -12,6 +12,7 @@ import { AuthenticationService } from 'ngx-login-client'; import { IterationQuery, IterationUI } from '../../models/iteration.model'; import { IterationService } from '../../services/iteration.service'; +import { AND, EQUAL } from '../../services/query-keys'; import { WorkItemService } from '../../services/work-item.service'; import { FabPlannerIterationModalComponent } from '../iterations-modal/iterations-modal.component'; import { FilterService } from './../../services/filter.service'; @@ -110,12 +111,12 @@ export class IterationComponent implements OnInit, OnDestroy, OnChanges { constructURL(iterationId: string) { //Query for work item type group - const type_query = this.filterService.queryBuilder('typegroup.name', this.filterService.equal_notation, this.witGroup.name); + const type_query = this.filterService.queryBuilder('typegroup.name', EQUAL, this.witGroup.name); //Query for iteration - const iteration_query = this.filterService.queryBuilder('iteration', this.filterService.equal_notation, iterationId); + const iteration_query = this.filterService.queryBuilder('iteration', EQUAL, iterationId); //Join type and space query - const first_join = this.filterService.queryJoiner({}, this.filterService.and_notation, type_query); - const second_join = this.filterService.queryJoiner(first_join, this.filterService.and_notation, iteration_query); + const first_join = this.filterService.queryJoiner({}, AND, type_query); + const second_join = this.filterService.queryJoiner(first_join, AND, iteration_query); //this.setGroupType(witGroup); //second_join gives json object return this.filterService.jsonToQuery(second_join); @@ -123,12 +124,12 @@ export class IterationComponent implements OnInit, OnDestroy, OnChanges { constructURLforBoard(iterationId: string) { //Query for work item type group - const type_query = this.filterService.queryBuilder('boardContextId', this.filterService.equal_notation, this.witGroup.id); + const type_query = this.filterService.queryBuilder('boardContextId', EQUAL, this.witGroup.id); //Query for iteration - const iteration_query = this.filterService.queryBuilder('iteration', this.filterService.equal_notation, iterationId); + const iteration_query = this.filterService.queryBuilder('iteration', EQUAL, iterationId); // join type and iteration query - const first_join = this.filterService.queryJoiner({}, this.filterService.and_notation, type_query); - const second_join = this.filterService.queryJoiner(first_join, this.filterService.and_notation, iteration_query); + const first_join = this.filterService.queryJoiner({}, AND, type_query); + const second_join = this.filterService.queryJoiner(first_join, AND, iteration_query); return this.filterService.jsonToQuery(second_join); } diff --git a/src/app/components_ngrx/labels/labels.component.ts b/src/app/components_ngrx/labels/labels.component.ts index 6b4f740d0..4270e2e9d 100644 --- a/src/app/components_ngrx/labels/labels.component.ts +++ b/src/app/components_ngrx/labels/labels.component.ts @@ -6,8 +6,10 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { cloneDeep } from 'lodash'; +import { AND, EQUAL } from '../../services/query-keys'; import { LabelUI } from './../../models/label.model'; import { FilterService } from './../../services/filter.service'; + @Component({ selector: 'f8-label', templateUrl: './labels.component.html', @@ -58,7 +60,7 @@ export class LabelsComponent { let showCompleted: boolean = this.queryParams.hasOwnProperty('showCompleted'); const newQuery = this.filterService.queryBuilder( 'label', - this.filterService.equal_notation, + EQUAL, labelId ); let existingQuery = {}; @@ -68,7 +70,7 @@ export class LabelsComponent { const finalQuery = this.filterService.jsonToQuery( this.filterService.queryJoiner( existingQuery, - this.filterService.and_notation, + AND, newQuery ) ); diff --git a/src/app/components_ngrx/planner-board/planner-board.component.ts b/src/app/components_ngrx/planner-board/planner-board.component.ts index d30e44966..b935e7b7e 100644 --- a/src/app/components_ngrx/planner-board/planner-board.component.ts +++ b/src/app/components_ngrx/planner-board/planner-board.component.ts @@ -15,6 +15,7 @@ import { filter, map, switchMap, take, takeUntil, tap } from 'rxjs/operators'; import { BoardQuery, BoardUIQuery } from '../../models/board.model'; import { WorkItemQuery, WorkItemUI } from '../../models/work-item'; import { FilterService } from '../../services/filter.service'; +import { AND, EQUAL } from '../../services/query-keys'; import { AppState } from '../../states/app.state'; import * as BoardUIActions from './../../actions/board-ui.actions'; import * as ColumnWorkItemAction from './../../actions/column-workitem.action'; @@ -101,12 +102,12 @@ export class PlannerBoardComponent implements AfterViewChecked, OnInit, OnDestro constructUrl(witGroup: GroupTypeUI) { //Query for work item type group const type_query = this.filterService.queryBuilder( - 'boardContextId', this.filterService.equal_notation, witGroup.id + 'boardContextId', EQUAL, witGroup.id ); //Query for space //Join type and space query const first_join = this.filterService.queryJoiner( - {}, this.filterService.and_notation, type_query + {}, AND, type_query ); //second_join gives json object return this.filterService.jsonToQuery(first_join); diff --git a/src/app/components_ngrx/planner-list/planner-list.component.ts b/src/app/components_ngrx/planner-list/planner-list.component.ts index f0a686017..1e2137f3c 100644 --- a/src/app/components_ngrx/planner-list/planner-list.component.ts +++ b/src/app/components_ngrx/planner-list/planner-list.component.ts @@ -18,6 +18,7 @@ import { EmptyStateConfig } from 'patternfly-ng/empty-state'; import { combineLatest, Observable } from 'rxjs'; import { filter, switchMap, take, tap } from 'rxjs/operators'; import { WorkItemTypeQuery, WorkItemTypeUI } from '../../models/work-item-type'; +import { AND, EQUAL, NOT_EQUAL } from '../../services/query-keys'; import { IterationQuery, IterationUI } from './../../models/iteration.model'; import { CookieService } from './../../services/cookie.service'; import { FilterService } from './../../services/filter.service'; @@ -165,15 +166,15 @@ export class PlannerListComponent implements OnInit, OnDestroy, AfterViewChecked ['closed', 'Done', 'Removed', 'Closed'].forEach(state => { stateQuery = this.filterService.queryJoiner( stateQuery, - this.filterService.and_notation, + AND, this.filterService.queryBuilder( - 'state', this.filterService.not_equal_notation, state + 'state', NOT_EQUAL, state ) ); }); exp = this.filterService.queryJoiner( exp, - this.filterService.and_notation, + AND, stateQuery ); } else { @@ -290,10 +291,10 @@ export class PlannerListComponent implements OnInit, OnDestroy, AfterViewChecked .subscribe(groupType => { const defaultGroupName = groupType.name; //Query for work item type group - const type_query = this.filterService.queryBuilder('typegroup.name', this.filterService.equal_notation, defaultGroupName); + const type_query = this.filterService.queryBuilder('typegroup.name', EQUAL, defaultGroupName); //Join type and space query - const first_join = this.filterService.queryJoiner({}, this.filterService.and_notation, type_query); - //const view_query = this.filterService.queryBuilder('tree-view', this.filterService.equal_notation, 'true'); + const first_join = this.filterService.queryJoiner({}, AND, type_query); + //const view_query = this.filterService.queryBuilder('tree-view', EQUAL, 'true'); //const third_join = this.filterService.queryJoiner(second_join); //second_join gives json object let query = this.filterService.jsonToQuery(first_join); @@ -436,7 +437,7 @@ export class PlannerListComponent implements OnInit, OnDestroy, AfterViewChecked let queryParams = cloneDeep(this.route.snapshot.queryParams); const newQuery = this.filterService.queryBuilder( 'label', - this.filterService.equal_notation, + EQUAL, labelId ); let existingQuery = {}; @@ -446,7 +447,7 @@ export class PlannerListComponent implements OnInit, OnDestroy, AfterViewChecked const finalQuery = this.filterService.jsonToQuery( this.filterService.queryJoiner( existingQuery, - this.filterService.and_notation, + AND, newQuery ) ); diff --git a/src/app/components_ngrx/planner-query/planner-query.component.html b/src/app/components_ngrx/planner-query/planner-query.component.html index 2a1716a22..38c795139 100644 --- a/src/app/components_ngrx/planner-query/planner-query.component.html +++ b/src/app/components_ngrx/planner-query/planner-query.component.html @@ -15,18 +15,37 @@

Please wait, we are loading your data.

- - + Press Enter to Search....
; + + public valueLoading: boolean = false; workItemsSource: Observable = combineLatest( this.spaceQuery.getCurrentSpace.pipe(filter(s => !!s)), @@ -40,6 +48,7 @@ export class PlannerQueryComponent implements OnInit, OnDestroy, AfterViewChecke // Wait untill workItemTypes are loaded this.workItemTypeQuery.getWorkItemTypes().pipe(filter(wt => !!wt.length))) .pipe( + delay(500), switchMap(([space, query]) => { if (query.hasOwnProperty('q')) { this.searchQuery = query.q; @@ -77,6 +86,8 @@ export class PlannerQueryComponent implements OnInit, OnDestroy, AfterViewChecke public targetHeight: number; public addDisabled: Observable = this.permissionQuery.isAllowedToAdd(); + public isSuggestionDropdownOpen: boolean; + public keyManager: ActiveDescendantKeyManager; private eventListeners: any[] = []; private hdrHeight: number = 0; @@ -88,6 +99,20 @@ export class PlannerQueryComponent implements OnInit, OnDestroy, AfterViewChecke private scrollCheckedFor: number = 0; private isQuickPreviewOpen: boolean; + private querySuggestion: Observable = + this.querySuggestionService.getSuggestions().pipe( + filter(s => !!s), + delay(500), + tap(() => this.valueLoading = false), + tap(s => { + if (s.length > 0) { + this.isSuggestionDropdownOpen = true; + } else { + this.isSuggestionDropdownOpen = false; + } + }) + ); + constructor( private cookieService: CookieService, private spaceQuery: SpaceQuery, @@ -100,7 +125,8 @@ export class PlannerQueryComponent implements OnInit, OnDestroy, AfterViewChecke private workItemTypeQuery: WorkItemTypeQuery, private urlService: UrlService, private el: ElementRef, - private permissionQuery: PermissionQuery + private permissionQuery: PermissionQuery, + private querySuggestionService: QuerySuggestionService ) {} ngOnInit() { @@ -163,28 +189,101 @@ export class PlannerQueryComponent implements OnInit, OnDestroy, AfterViewChecke } } + onInputKeyPress(event: KeyboardEvent) { + let keycode = event.keyCode ? event.keyCode : event.which; + if (keycode === UP_ARROW || keycode === DOWN_ARROW) { + event.preventDefault(); + } + } - fetchWorkItemForQuery(event: KeyboardEvent, query: string) { + fetchWorkItemForQuery(event: KeyboardEvent, query: string, cursorPosition: number) { let keycode = event.keyCode ? event.keyCode : event.which; - let queryParams = cloneDeep(this.route.snapshot.queryParams); - if (keycode === 13 && query !== '') { - if (queryParams.hasOwnProperty('prevq')) { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { - q : query, - prevq: queryParams.prevq - } - }); + // If Enter pressed + if (this.isSuggestionDropdownOpen) { + if (keycode === 13 && this.keyManager.activeItem) { + this.onSelectSuggestion( + this.keyManager.activeItem.item, query, cursorPosition + ); + this.keyManager.setActiveItem(null); + } else if (keycode === 13 && !this.keyManager.activeItem) { + this.executeQuery(query); + } else if (keycode === UP_ARROW || keycode === DOWN_ARROW) { + event.preventDefault(); + this.keyManager.onKeydown(event); } else { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { q : query} - }); - } + this.valueLoading = true; + this.querySuggestionService.queryObservable.next( + query + ); + } + } else if (!this.isSuggestionDropdownOpen) { + if (keycode === 13 && query !== '') { + this.executeQuery(query); + } else { + this.valueLoading = true; + this.querySuggestionService.queryObservable.next( + query + ); + } } else if (keycode === 8 && (event.ctrlKey || event.metaKey)) { this.searchQuery = ''; } + + if (keycode === LEFT_ARROW || keycode === RIGHT_ARROW) { + this.querySuggestionService.queryObservable.next( + this.getTextTillCurrentCursor() + ); + } + } + + executeQuery(query) { + let queryParams = cloneDeep(this.route.snapshot.queryParams); + if (queryParams.hasOwnProperty('prevq')) { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + q : query, + prevq: queryParams.prevq + } + }); + } else { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { q : query} + }); + } + } + + private getTextTillCurrentCursor() { + return this.searchField.nativeElement.value.slice( + 0, this.searchField.nativeElement.selectionStart + ); + } + + onSelectSuggestion(suggestion: string, input: string, cursorPosition: number): void { + this.searchField.nativeElement.value = this.querySuggestionService.replaceSuggestion( + input.slice(0, cursorPosition), + input.slice(cursorPosition), + suggestion + ); + this.querySuggestionService.queryObservable.next('-'); + this.searchField.nativeElement.focus(); + } + + onClickSearchField(event) { + this.valueLoading = true; + this.querySuggestionService.queryObservable.next( + this.getTextTillCurrentCursor() + ); + } + + clearInputField() { + this.searchQuery = ''; + this.searchField.nativeElement.focus(); + } + + onBlurSearchField(event) { + this.querySuggestionService.queryObservable.next('-'); } onChildExploration(workItem: WorkItemUI) { @@ -285,6 +384,7 @@ export class PlannerQueryComponent implements OnInit, OnDestroy, AfterViewChecke ngAfterViewInit() { this.setDataTableColumns(); + this.keyManager = new ActiveDescendantKeyManager(this.dropdownOptions).withWrap().withTypeAhead(); } @HostListener('window:resize', ['$event']) diff --git a/src/app/components_ngrx/planner-query/planner-query.module.ts b/src/app/components_ngrx/planner-query/planner-query.module.ts index c813884f9..1756d9b53 100644 --- a/src/app/components_ngrx/planner-query/planner-query.module.ts +++ b/src/app/components_ngrx/planner-query/planner-query.module.ts @@ -24,9 +24,11 @@ import { PlannerQueryComponent } from './planner-query.component'; import { InfiniteScrollModule } from 'ngx-widgets'; import { ErrorHandler } from '../../effects/work-item-utils'; import { WorkItemTypeQuery } from '../../models/work-item-type'; +import { QuerySuggestionService } from '../../services/query-suggestion.service'; import { UrlService } from '../../services/url.service'; import { NgLetModule } from '../../shared/ng-let'; import { ClickOutModule } from '../../widgets/clickout/clickout.module'; +import { ListItemModule } from '../../widgets/list-item/list-item.module'; import { WorkItemQuickAddModule } from '../work-item-quick-add/work-item-quick-add.module'; @NgModule({ @@ -46,7 +48,8 @@ import { WorkItemQuickAddModule } from '../work-item-quick-add/work-item-quick-a WorkItemQuickAddModule, InfiniteScrollModule, NgLetModule, - PlannerModalModule + PlannerModalModule, + ListItemModule ], declarations: [PlannerQueryComponent], exports: [PlannerQueryComponent], @@ -59,7 +62,8 @@ import { WorkItemQuickAddModule } from '../work-item-quick-add/work-item-quick-a TooltipConfig, UrlService, WorkItemTypeQuery, - ErrorHandler + ErrorHandler, + QuerySuggestionService ] }) export class PlannerQueryModule {} diff --git a/src/app/components_ngrx/toolbar-panel/toolbar-panel.component.ts b/src/app/components_ngrx/toolbar-panel/toolbar-panel.component.ts index bac24f1bc..1ba0bcb29 100644 --- a/src/app/components_ngrx/toolbar-panel/toolbar-panel.component.ts +++ b/src/app/components_ngrx/toolbar-panel/toolbar-panel.component.ts @@ -31,6 +31,7 @@ import { FilterModel } from '../../models/filter.model'; import { WorkItem, WorkItemQuery } from '../../models/work-item'; import { WorkItemTypeQuery, WorkItemTypeUI } from '../../models/work-item-type'; import { FilterService } from '../../services/filter.service'; +import { AND, EQUAL } from '../../services/query-keys'; import { GroupTypeQuery, GroupTypeUI } from './../../models/group-types.model'; import { IterationQuery, IterationUI } from './../../models/iteration.model'; import { LabelQuery, LabelUI } from './../../models/label.model'; @@ -273,12 +274,12 @@ export class ToolbarPanelComponent implements OnInit, AfterViewInit, OnDestroy { $event.query.id : $event.value; const newQuery = this.filterService.queryBuilder( field, - this.filterService.equal_notation, + EQUAL, value ); const finalQuery = this.filterService.queryJoiner( oldQueryJson, - this.filterService.and_notation, + AND, newQuery ); const queryString = this.filterService.jsonToQuery(finalQuery); diff --git a/src/app/effects/work-item-utils.ts b/src/app/effects/work-item-utils.ts index bd0a88c95..952dca536 100644 --- a/src/app/effects/work-item-utils.ts +++ b/src/app/effects/work-item-utils.ts @@ -7,6 +7,7 @@ import { map } from 'rxjs/operators'; import { withLatestFrom } from 'rxjs/operators'; import { WorkItemUI } from '../models/work-item'; import { FilterService } from '../services/filter.service'; +import { AND, EQUAL } from '../services/query-keys'; import { WorkItemService } from '../services/work-item.service'; @@ -55,18 +56,18 @@ export function workitemMatchesFilter(route, return ObservableOf(workitem); } else { const wiQuery = filterService.queryBuilder( - 'number', filterService.equal_notation, workitem.number.toString() + 'number', EQUAL, workitem.number.toString() ); const exp = filterService.queryJoiner( filterService.queryToJson(currentRoute['q']), - filterService.and_notation, + AND, wiQuery ); const spaceQuery = filterService.queryBuilder( - 'space', filterService.equal_notation, spaceId + 'space', EQUAL, spaceId ); const finalQuery = filterService.queryJoiner( - exp, filterService.and_notation, spaceQuery + exp, AND, spaceQuery ); const searchPayload = { expression: finalQuery diff --git a/src/app/effects/work-item.effects.ts b/src/app/effects/work-item.effects.ts index dbbe5fed6..2074a0ee2 100644 --- a/src/app/effects/work-item.effects.ts +++ b/src/app/effects/work-item.effects.ts @@ -8,6 +8,7 @@ import { empty, Observable, of } from 'rxjs'; import { catchError, map, mergeMap, switchMap } from 'rxjs/operators'; import { cleanObject } from '../models/common.model'; import { FilterService } from '../services/filter.service'; +import { AND, EQUAL } from '../services/query-keys'; import * as BoardUIActions from './../actions/board-ui.actions'; import * as ColumnWorkItemActions from './../actions/column-workitem.action'; import * as WorkItemActions from './../actions/work-item.actions'; @@ -16,6 +17,7 @@ import { WorkItemService as WIService } from './../services/work-item.service'; import { AppState } from './../states/app.state'; import * as util from './work-item-utils'; + export type Action = WorkItemActions.All | ColumnWorkItemActions.All | BoardUIActions.All; @Injectable() @@ -159,10 +161,10 @@ export class WorkItemEffects { const payload = wp.payload; const state = wp.state; const spaceQuery = this.filterService.queryBuilder( - 'space', this.filterService.equal_notation, state.space.id + 'space', EQUAL, state.space.id ); const finalQuery = this.filterService.queryJoiner( - payload.filters, this.filterService.and_notation, spaceQuery + payload.filters, AND, spaceQuery ); return this.workItemService.getWorkItems(payload.pageSize, {expression: finalQuery}) .pipe( diff --git a/src/app/models/area.model.ts b/src/app/models/area.model.ts index bc3c4f3cb..b7e0a84e7 100644 --- a/src/app/models/area.model.ts +++ b/src/app/models/area.model.ts @@ -118,6 +118,16 @@ export class AreaQuery { ); } + getAreaIds(): Observable { + return this.areaSource.pipe(map(areas => Object.keys(areas))); + } + + getAreaNames(): Observable { + return this.getAreas().pipe( + map(areas => areas.map(a => a.name)) + ); + } + getAreaObservableById(id: string): Observable { return this.areaSource.pipe(select(areas => areas[id])); } diff --git a/src/app/models/board.model.ts b/src/app/models/board.model.ts index 1627692ef..1cc2ac53b 100644 --- a/src/app/models/board.model.ts +++ b/src/app/models/board.model.ts @@ -7,6 +7,7 @@ import { Injectable } from '@angular/core'; import { createFeatureSelector, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { filter, map, switchMap, tap } from 'rxjs/operators'; +import { AND, EQUAL } from '../services/query-keys'; import * as WorkItemActions from './../actions/work-item.actions'; import { FilterService } from './../services/filter.service'; import { BoardViewState } from './../states/app.state'; @@ -143,17 +144,17 @@ export class BoardQuery { filter(board => !!board), tap((board) => { const boardQuery = this.filterService.queryBuilder( - 'board.id', this.filterService.equal_notation, board.id + 'board.id', EQUAL, board.id ); let finalQuery = this.filterService.queryJoiner( - {}, this.filterService.and_notation, boardQuery + {}, AND, boardQuery ); if (iterationID !== '') { const iterationQuery = this.filterService.queryBuilder( - 'iteration', this.filterService.equal_notation, iterationID + 'iteration', EQUAL, iterationID ); finalQuery = this.filterService.queryJoiner( - finalQuery, this.filterService.and_notation, iterationQuery + finalQuery, AND, iterationQuery ); } this.store.dispatch(new WorkItemActions.Get({ diff --git a/src/app/models/iteration.model.ts b/src/app/models/iteration.model.ts index 89331a578..e40333252 100644 --- a/src/app/models/iteration.model.ts +++ b/src/app/models/iteration.model.ts @@ -284,4 +284,18 @@ export class IterationQuery { deselectAllIteration() { this.store.dispatch(new IterationActions.Select()); } + + get getIterationIds(): Observable { + return this.iterationSource.pipe( + map(iterations => { + return Object.keys(iterations); + }) + ); + } + + get getIterationNames(): Observable { + return this.getIterations().pipe( + map(iterations => iterations.map(it => it.name)) + ); + } } diff --git a/src/app/models/label.model.ts b/src/app/models/label.model.ts index db0636d9d..1b98452d8 100644 --- a/src/app/models/label.model.ts +++ b/src/app/models/label.model.ts @@ -148,6 +148,12 @@ export class LabelQuery { return this.store.pipe(select(this.getAllLabelsSelector)); } + get getlabelNames(): Observable { + return this.getLables().pipe( + map(labels => labels.map(label => label.name)) + ); + } + getLabelObservableById(number: string): Observable { const labelSelector = createSelector( this.getLabelEntities, diff --git a/src/app/models/work-item.spec.ts b/src/app/models/work-item.spec.ts index 4327dc660..bf9f56a77 100644 --- a/src/app/models/work-item.spec.ts +++ b/src/app/models/work-item.spec.ts @@ -434,7 +434,8 @@ describe('WorkItemQuery :: ', () => { 1: {id: '1'} as WorkItemUI, 2: {id: '2'} as WorkItemUI, 3: {id: '3'} as WorkItemUI - } + }, + nextLink: '' } } }; diff --git a/src/app/services/filter.service.spec.ts b/src/app/services/filter.service.spec.ts index bfc856af0..5b521fc0d 100644 --- a/src/app/services/filter.service.spec.ts +++ b/src/app/services/filter.service.spec.ts @@ -13,6 +13,7 @@ import { FilterModel } from '../models/filter.model'; import { WorkItem } from './../models/work-item'; import { HttpClientService } from './../shared/http-module/http.service'; import { FilterService } from './filter.service'; +import { AND, ENCLOSURE, EQUAL, IN } from './query-keys'; describe('Unit Test :: Filter Service', () => { let filterService: FilterService; @@ -203,15 +204,15 @@ describe('Unit Test :: Filter Service', () => { * Tests to check constructQueryURL function * Test cases * - * Input - ('iteration%3Asprint%20%231%2Fsprint%20%231.1', {'parentexists': true}) - * Output - '(iteration%3Asprint%20%231%2Fsprint%20%231.1)%20$AND%20(parentexists%3Atrue)' + * Input - ('iteration%3A%22sprint%20%231%2Fsprint%20%231.1%22', {'parentexists': true}) + * Output - '(iteration%3A%22sprint%20%231%2Fsprint%20%231.1%22)%20$AND%20(parentexists%3Atrue)' */ it('should return existing query in case of empty options', () => { expect( - filterService.constructQueryURL('iteration%3Asprint%20%231%2Fsprint%20%231.1', {}) + filterService.constructQueryURL('iteration%3A%22sprint%20%231%2Fsprint%20%231.1%22', {}) ).toBe( - 'iteration:sprint #1/sprint #1.1' + 'iteration:"sprint #1/sprint #1.1"' ); }); @@ -227,23 +228,23 @@ describe('Unit Test :: Filter Service', () => { expect( filterService.constructQueryURL('', {'parentexists': true, 'iteration': 'Sprint #1/Sprint #1.1'}) ).toBe( - '(parentexists:true $AND iteration:Sprint #1/Sprint #1.1)' + '(parentexists:true $AND iteration:"Sprint #1/Sprint #1.1")' ); }); it('should return processed options in case of empty existing query', () => { expect( - filterService.constructQueryURL('iteration%3ASprint%20%231%2FSprint%20%231.1', {'parentexists': true}) + filterService.constructQueryURL('iteration%3A%22Sprint%20%231%2FSprint%20%231.1%22', {'parentexists': true}) ).toBe( - '(iteration:Sprint #1/Sprint #1.1 $AND parentexists:true)' + '(iteration:"Sprint #1/Sprint #1.1" $AND parentexists:true)' ); }); it('should return processed options in case of empty existing query - 1', () => { expect( - filterService.constructQueryURL('iteration%3ASprint%20%231%2FSprint%20%231.1', {'somekey': 'somevalue, anothervalue'}) + filterService.constructQueryURL('iteration%3A%22Sprint%20%231%2FSprint%20%231.1%22', {'somekey': 'somevalue, anothervalue'}) ).toBe( - '(iteration:Sprint #1/Sprint #1.1 $AND somekey:somevalue $AND somekey: anothervalue)' + '(iteration:"Sprint #1/Sprint #1.1" $AND somekey:somevalue $AND somekey:anothervalue)' ); }); @@ -318,8 +319,8 @@ describe('Unit Test :: Filter Service', () => { }); it('should return correct query string - 11', () => { - expect(filterService.jsonToQuery({'$OR': [{'a': {'$EQ': 'b'}}, {'$AND': [{'c': {'$EQ': 'd'}}, {'d': {'$EQ': 'e'}}, {'$OR': [{'l': {'$EQ': 'm'}}, {'n': {'$EQ': 'p'}}]}, {'f': {'$EQ': 'g'}}]}]})) - .toBe('(a:b $OR (c:d $AND d:e $AND (l:m $OR n:p) $AND f:g))'); + expect(filterService.jsonToQuery({'$OR': [{'a': {'$EQ': 'b'}}, {'$AND': [{'c': {'$EQ': 'd a'}}, {'d': {'$EQ': 'e'}}, {'$OR': [{'l': {'$EQ': 'm'}}, {'n': {'$EQ': 'p'}}]}, {'f': {'$NE': 'g e'}}]}]})) + .toBe('(a:b $OR (c:"d a" $AND d:e $AND (l:m $OR n:p) $AND f!"g e"))'); }); it('should return correct query string - 12', () => { @@ -419,7 +420,7 @@ describe('Unit Test :: Filter Service', () => { expect( filterService.queryBuilder( 'some_key', - filterService.equal_notation, + EQUAL, 'some_value' ) ) @@ -432,7 +433,7 @@ describe('Unit Test :: Filter Service', () => { expect( filterService.queryBuilder( 'some_key', - filterService.equal_notation, + EQUAL, ['some_value', 'some_value1', 'some_value2', 'some_value3'] ) ) @@ -624,7 +625,7 @@ describe('Unit Test :: Filter Service', () => { // Build type query const wi_key = 'workitemtype'; - const wi_compare = filterService.in_notation; + const wi_compare = IN; const wi_value = ['type_id_1', 'type_id_2']; const type_query = filterService.queryBuilder(wi_key, wi_compare, wi_value); @@ -639,7 +640,7 @@ describe('Unit Test :: Filter Service', () => { // Build space query const s_key = 'space'; - const s_compare = filterService.equal_notation; + const s_compare = EQUAL; const s_value = 'space_id_1'; const space_query = filterService.queryBuilder(s_key, s_compare, s_value); @@ -673,6 +674,10 @@ describe('Unit Test :: Filter Service', () => { expect(filterService.queryToJson('typegroup.name:Work Items $AND iteration.name:Sprint #101')).toEqual({'$AND': [{'typegroup.name': {'$EQ': 'Work Items'}}, {'iteration.name': {'$EQ': 'Sprint #101'}}]}); }); + it('should convert to json with quote in value', () => { + expect(filterService.queryToJson('typegroup.name:"Work Items" $AND iteration.name:"Sprint #101"')).toEqual({'$AND': [{'typegroup.name': {'$EQ': 'Work Items'}}, {'iteration.name': {'$EQ': 'Sprint #101'}}]}); + }); + it('should get a condition form query', () => { expect(filterService.getConditionFromQuery('typegroup.name:Work Items', 'typegroup.name')).toEqual('Work Items'); }); @@ -685,4 +690,37 @@ describe('Unit Test :: Filter Service', () => { expect(filterService.getConditionFromQuery('typegroup.name:Work Items $OR iteration.name:Sprint #101', 'typegroup.name')).toEqual(undefined); }); + /** + * Tets for string enclouser function + */ + + it('encloseValue :: should enclose the string with space - 1', () => { + expect(filterService.encloseValue('The string has space')).toBe( + ENCLOSURE + 'The string has space' + ENCLOSURE + ); + }); + + it('encloseValue :: should not enclose the string with space and already enclosed - 1', () => { + expect(filterService.encloseValue(ENCLOSURE + 'The string has space' + ENCLOSURE)).toBe( + ENCLOSURE + 'The string has space' + ENCLOSURE + ); + }); + + it('encloseValue :: should not enclose the string with no space - 2', () => { + expect(filterService.encloseValue('Thestringhasnospace')).toBe( + 'Thestringhasnospace' + ); + }); + + it('clearEnclosedValue :: should clear the enclosed string with space - 1', () => { + expect(filterService.clearEnclosedValue(ENCLOSURE + 'The string has space' + ENCLOSURE)).toBe( + 'The string has space' + ); + }); + + it('clearEnclosedValue :: should clear the enclosed string with no space - 2', () => { + expect(filterService.clearEnclosedValue('Thestringhasnospace')).toBe( + 'Thestringhasnospace' + ); + }); }); diff --git a/src/app/services/filter.service.ts b/src/app/services/filter.service.ts index 036fe4135..ca95e6103 100644 --- a/src/app/services/filter.service.ts +++ b/src/app/services/filter.service.ts @@ -6,21 +6,26 @@ import { FilterModel } from '../models/filter.model'; import { HttpClientService } from '../shared/http-module/http.service'; import { WorkItem } from './../models/work-item'; +import { + AND, ENCLOSURE, EQUAL, IN, + NOT_EQUAL, NOT_IN, OR, P_END, + P_START, SUB_STR +} from './query-keys'; + @Injectable() export class FilterService { public filters: FilterModel[] = []; public activeFilters = []; public filterChange = new Subject(); public filterObservable: Subject = new Subject(); - - public and_notation = '$AND'; - public or_notation = '$OR'; - public equal_notation = '$EQ'; - public not_equal_notation = '$NE'; - public in_notation = '$IN'; - public not_in_notation = '$NIN'; - public sub_str_notation = '$SUBSTR'; - + public and_notation = AND; + public or_notation = OR; + public equal_notation = EQUAL; + public not_equal_notation = NOT_EQUAL; + public in_notation = IN; + public not_in_notation = NOT_IN; + public sub_str_notation = SUB_STR; + public str_enclouser = ENCLOSURE; public special_keys = { 'null': null, 'true': true, @@ -29,16 +34,16 @@ export class FilterService { }; private compare_notations: string[] = [ - this.equal_notation, - this.not_equal_notation, - this.in_notation, - this.not_in_notation, - this.sub_str_notation + EQUAL, + NOT_EQUAL, + IN, + NOT_IN, + SUB_STR ]; private join_notations: string[] = [ - this.and_notation, - this.or_notation + AND, + OR ]; private filtertoWorkItemMap = { @@ -101,10 +106,10 @@ export class FilterService { let refCurrentFilter = []; if (this.route.snapshot.queryParams['q']) { let urlString = this.route.snapshot.queryParams['q'] - .replace(' $AND ', ' ') - .replace(' $OR ', ' ') - .replace('(', '') - .replace(')', ''); + .replace(' ' + AND + ' ', ' ') + .replace(' ' + OR + ' ', ' ') + .replace(P_START, '') + .replace(P_END, ''); let temp_arr = urlString.split(' '); for (let i = 0; i < temp_arr.length; i++) { let arr = temp_arr[i].split(':'); @@ -194,6 +199,40 @@ export class FilterService { }); } + /** + * This function encloses the query value within quotes + * only if the string contains any space + * value with spaces should never be without enclouser + * for ease of coding and understanding + * @param query + */ + encloseValue(query: string): any { + // the value could be + // null, true, false all special values + if (typeof query !== 'string') { + return query; + } + // If there is a space in between + // and there no enclouser already + // then enclosed the string and return it + if ( + query.split(' ').length > 1 && + !(query[0] === ENCLOSURE && query[query.length - 1] === ENCLOSURE)) { + return ENCLOSURE + query + ENCLOSURE; + } + return query; + } + + /** + * This function clears the quote from the value + * @param query + */ + clearEnclosedValue(query: string) { + if (query[0] === ENCLOSURE && query[query.length - 1] === ENCLOSURE) { + return query.substr(1, query.length - 2); + } + return query; + } /** * Take the existing query and simply AND it with provided options @@ -204,19 +243,19 @@ export class FilterService { let processedObject = ''; // If onptions has any length enclose processedObject with () if (Object.keys(options).length > 1) { - processedObject = '(' + Object.keys(options).map(key => { + processedObject = P_START + Object.keys(options).map(key => { return typeof(options[key]) !== 'string' ? key + ':' + options[key] : options[key].split(',').map(val => { - return key + ':' + val; - }).join(' ' + this.and_notation + ' '); - }).join(' ' + this.and_notation + ' ') + ')'; + return key + ':' + this.encloseValue(val.trim()); + }).join(' ' + AND + ' '); + }).join(' ' + AND + ' ') + P_END; } else if (Object.keys(options).length === 1) { processedObject = Object.keys(options).map(key => { return typeof(options[key]) !== 'string' ? key + ':' + options[key] : options[key].split(',').map(val => { - return key + ':' + val; - }).join(' ' + this.and_notation + ' '); - }).join(' ' + this.and_notation + ' '); + return key + ':' + this.encloseValue(val.trim()); + }).join(' ' + AND + ' '); + }).join(' ' + AND + ' '); } else { return decodeURIComponent(existingQuery); } @@ -230,16 +269,16 @@ export class FilterService { let decodedURL = decodeURIComponent(existingQuery); // Check if there is any composite query in existing one - if (decodedURL.indexOf(this.and_notation) > -1 || decodedURL.indexOf(this.or_notation) > -1) { + if (decodedURL.indexOf(AND) > -1 || decodedURL.indexOf(OR) > -1) { // Check if existing query is a group i.e. enclosed - if (decodedURL[0] != '(' || decodedURL[decodedURL.length - 1] != ')') { + if (decodedURL[0] !== P_START || decodedURL[decodedURL.length - 1] !== P_END) { // enclose it with () - decodedURL = '(' + decodedURL + ')'; + decodedURL = P_START + decodedURL + P_END; } } // Add the query from option with AND operation - return '(' + decodedURL + ' ' + this.and_notation + ' ' + processedObject + ')'; + return P_START + decodedURL + ' ' + AND + ' ' + processedObject + P_END; } } @@ -247,9 +286,9 @@ export class FilterService { * * @param key The value is the object key like 'workitem_type', 'iteration' etc * @param compare The values are - * FilterService::equal_notation', - * FilterService::not_equal_notation', - * FilterService::not_equal_notation', + * FilterService::EQUAL', + * FilterService::not_EQUAL', + * FilterService::not_EQUAL', * FilterService::in_notation', * FilterService::not_in_notation' * @param value string or array of string of values (in case of IN or NOT IN) @@ -271,8 +310,8 @@ export class FilterService { * * @param existingQueryObject * @param join The values are - * FilterService::and_notation, - * FilterService::or_notation + * FilterService::AND, + * FilterService::OR * @param newQueryObject */ queryJoiner(existingQueryObject: object, join: string, newQueryObject: object): any { @@ -286,7 +325,7 @@ export class FilterService { return newQueryObject; } else { let op = {}; - op[this.or_notation] = [newQueryObject]; + op[OR] = [newQueryObject]; return op; } } else { @@ -388,15 +427,21 @@ export class FilterService { */ queryToJson(query: string, first_level: boolean = true): any { let temp = [], p_count = 0, p_start = -1, new_str = '', output = {}; + // counting on parenthesis + // Replacing highest level of enclosed querries with __temp__ + // for example - + // (a:b $AND c:d) $OR e:f becomes + // __temp__ $OR e:f + // tmp queue has a:b $AND c:d for (let i = 0; i < query.length; i++) { - if (query[i] === '(') { + if (query[i] === P_START) { if (p_start < 0) { p_start = i; } p_count += 1; } if (p_start === -1) { new_str += query[i]; } - if (query[i] === ')') { + if (query[i] === P_END) { p_count -= 1; } if (p_start >= 0 && p_count === 0) { @@ -405,10 +450,18 @@ export class FilterService { p_start = -1; } } + // In order to treat it as a queue we reverse the temp array temp.reverse(); - let arr = new_str.split(this.or_notation); + + // First split with $OR + let arr = new_str.split(OR); if (arr.length > 1) { - output[this.or_notation] = arr.map(item => { + // Each element after the split will be either + // a singular query i.e. some_key: some_value + // or some query with or without __temp__ in it + // We replace the __temp__ with the actual string + // Pass it through the same function + output[OR] = arr.map(item => { item = item.trim(); if (item == '__temp__') { item = temp.pop(); @@ -419,9 +472,11 @@ export class FilterService { return this.queryToJson(item, false); }); } else { - arr = new_str.split(this.and_notation); + // Next split the item by $AND + arr = new_str.split(AND); if (arr.length > 1) { - output[this.and_notation] = arr.map(item => { + // Do the same as earlier + output[AND] = arr.map(item => { if (item.trim() == '__temp__') { item = temp.pop(); } @@ -432,7 +487,7 @@ export class FilterService { while (new_str.indexOf('__temp__') > -1) { new_str = new_str.replace('__temp__', temp.pop()); } - if (new_str.indexOf(this.and_notation) > -1 || new_str.indexOf(this.or_notation) > -1) { + if (new_str.indexOf(AND) > -1 || new_str.indexOf(OR) > -1) { return this.queryToJson(new_str, false); } let keyIndex = -1; @@ -450,31 +505,31 @@ export class FilterService { dObj[key] = {}; if (splitter === '!') { if (val_arr.length > 1) { - dObj[key][this.not_in_notation] = val_arr; + dObj[key][NOT_IN] = val_arr; } else { if (Object.keys(this.special_keys).findIndex(k => k === val_arr[0]) > -1) { - dObj[key][this.not_equal_notation] = this.special_keys[val_arr[0]]; + dObj[key][NOT_EQUAL] = this.special_keys[val_arr[0]]; } else if (key === 'title') { - dObj[key][this.sub_str_notation] = val_arr[0]; + dObj[key][SUB_STR] = this.clearEnclosedValue(val_arr[0]); } else { - dObj[key][this.not_equal_notation] = val_arr[0]; + dObj[key][NOT_EQUAL] = this.clearEnclosedValue(val_arr[0]); } } } else if (splitter === ':') { if (val_arr.length > 1) { - dObj[key][this.in_notation] = val_arr; + dObj[key][IN] = val_arr; } else { if (Object.keys(this.special_keys).findIndex(k => k === val_arr[0]) > -1) { - dObj[key][this.equal_notation] = this.special_keys[val_arr[0]]; + dObj[key][EQUAL] = this.special_keys[val_arr[0]]; } else if (key === 'title') { - dObj[key][this.sub_str_notation] = val_arr[0]; + dObj[key][SUB_STR] = this.clearEnclosedValue(val_arr[0]); } else { - dObj[key][this.equal_notation] = val_arr[0]; + dObj[key][EQUAL] = this.clearEnclosedValue(val_arr[0]); } } } if (first_level) { - output[this.or_notation] = [dObj]; + output[OR] = [dObj]; } else { return dObj; } @@ -487,8 +542,8 @@ export class FilterService { jsonToQuery(obj: object): string { let key = Object.keys(obj)[0]; // key will be AND or OR let value = obj[key]; - return '(' + value.map(item => { - if (Object.keys(item)[0] == this.and_notation || Object.keys(item)[0] == this.or_notation) { + return P_START + value.map(item => { + if (Object.keys(item)[0] == AND || Object.keys(item)[0] == OR) { return this.jsonToQuery(item); } else { let data_key = Object.keys(item)[0]; @@ -497,25 +552,25 @@ export class FilterService { let splitter: string = ''; switch (conditional_operator) { - case this.equal_notation: + case EQUAL: splitter = ':'; - return data_key + splitter + data[conditional_operator]; - case this.not_equal_notation: + return data_key + splitter + this.encloseValue(data[conditional_operator]); + case NOT_EQUAL: splitter = '!'; - return data_key + splitter + data[conditional_operator]; - case this.in_notation: + return data_key + splitter + this.encloseValue(data[conditional_operator]); + case IN: splitter = ':'; return data_key + splitter + data[conditional_operator].join(); - case this.not_in_notation: + case NOT_IN: splitter = '!'; return data_key + splitter + data[conditional_operator].join(); - case this.sub_str_notation: + case SUB_STR: splitter = ':'; - return data_key + splitter + data[conditional_operator]; + return data_key + splitter + this.encloseValue(data[conditional_operator]); } } }) - .join(' ' + key + ' ') + ')'; + .join(' ' + key + ' ') + P_END; } /** @@ -532,11 +587,11 @@ export class FilterService { if (queryString) { let decodedQuery = this.queryToJson(queryString); // we ignore non-AND queries for now, might want to extend that later. - if (!decodedQuery['$AND'] && !(decodedQuery['$OR'] && decodedQuery['$OR'].length === 1)) { + if (!decodedQuery[AND] && !(decodedQuery[OR] && decodedQuery[OR].length === 1)) { console.log('The current query is not supported by getConditionFromQuery() (non-AND query): ' + queryString); return undefined; } else { - let terms: any[] = decodedQuery['$AND'] ? decodedQuery['$AND'] : decodedQuery['$OR']; + let terms: any[] = decodedQuery[AND] ? decodedQuery[AND] : decodedQuery[OR]; if (terms || !Array.isArray(terms)) { for (let i = 0; i < terms.length; i++) { let thisTerm = terms[i]; @@ -559,7 +614,7 @@ export class FilterService { // Temporary function to deal with single level $AND operator queryToFlat(query: string) { return query.replace(/^\((.+)\)$/, '$1') - .split(this.and_notation).map((item, index) => { + .split(AND).map((item, index) => { // regex to match field:value pattern. // for item=title:A:D, field -> title and value -> A:D let filterValue = /(^[^:]+):(.*)$/.exec(item); @@ -576,12 +631,12 @@ export class FilterService { arr.forEach(item => { const newQuery = this.queryBuilder( item.field, - this.equal_notation, + EQUAL, item.value ); query = this.queryJoiner( query, - this.and_notation, + AND, newQuery ); }); @@ -597,11 +652,11 @@ export class FilterService { isOnlyChildQuery(query: string): string | null { try { const jsonQuery = this.queryToJson(query); - if (jsonQuery[this.or_notation] && - jsonQuery[this.or_notation].length === 1 && - jsonQuery[this.or_notation][0]['parent.number'] && - jsonQuery[this.or_notation][0]['parent.number'][this.equal_notation]) { - return jsonQuery[this.or_notation][0]['parent.number'][this.equal_notation]; + if (jsonQuery[OR] && + jsonQuery[OR].length === 1 && + jsonQuery[OR][0]['parent.number'] && + jsonQuery[OR][0]['parent.number'][EQUAL]) { + return jsonQuery[OR][0]['parent.number'][EQUAL]; } } catch {} return null; diff --git a/src/app/services/query-keys.ts b/src/app/services/query-keys.ts new file mode 100644 index 000000000..9f4467664 --- /dev/null +++ b/src/app/services/query-keys.ts @@ -0,0 +1,12 @@ +export const AND = '$AND'; +export const OR = '$OR'; +export const EQUAL = '$EQ'; +export const NOT_EQUAL = '$NE'; +export const IN = '$IN'; +export const NOT_IN = '$NIN'; +export const SUB_STR = '$SUBSTR'; +export const ENCLOSURE = '"'; +export const P_START = '('; +export const P_END = ')'; +export const EQUAL_QUERY = ':'; +export const NOT_EQUAL_QUERY = '!'; diff --git a/src/app/services/query-suggestion.service.spec.ts b/src/app/services/query-suggestion.service.spec.ts new file mode 100644 index 000000000..5015585c6 --- /dev/null +++ b/src/app/services/query-suggestion.service.spec.ts @@ -0,0 +1,238 @@ +import { async, getTestBed, TestBed } from '@angular/core/testing'; +import { UserService } from 'ngx-login-client'; +import { of } from 'rxjs'; +import { AreaQuery } from '../models/area.model'; +import { IterationQuery } from '../models/iteration.model'; +import { LabelQuery } from '../models/label.model'; +import { AND, OR, P_END, P_START } from './query-keys'; +import { QuerySuggestionService } from './query-suggestion.service'; + +class FakeAreaQuery { + getAreaIds() { + return of(['id-1', 'id-2', 'id-23']); + } + getAreaNames() { + return of(['area-1', 'area-2', 'area-23']); + } +} + +class FakeIterationQuery { + get getIterationIds() { + return of(['id-1', 'id-2', 'id-23']); + } + get getIterationNames() { + return of(['iteration-1', 'iteration-2', 'iteration-23']); + } +} + +class FakeUserService { + getUsersBySearchString(str) { + return of(['user-1', 'user-2', 'user-23']); + } +} + +class FakeLabelQuery { + get getlabelNames() { + return of(['label-1', 'label-2', 'label-23']); + } +} + +describe( + 'Unit Test :: QuerySuggestionService', + () => { + let queryService: QuerySuggestionService; + + beforeEach( + async(() => { + TestBed.configureTestingModule({ + providers: [ + QuerySuggestionService, + { + provide: AreaQuery, + useClass: FakeAreaQuery + }, + { + provide: IterationQuery, + useClass: FakeIterationQuery + }, + { + provide: UserService, + useClass: FakeUserService + }, + { + provide: LabelQuery, + useClass: FakeLabelQuery + } + ] + }); + const testBed = getTestBed(); + queryService = testBed.get(QuerySuggestionService); + }) + ); + + it('Should suggest for fields if there is no key yet ', () => { + expect(queryService.shouldSuggestField('hello').suggest).toBeTruthy(); + }); + + it('Should sugest fields if empty query is given', () => { + expect(queryService.shouldSuggestField('').suggest).toBeTruthy(); + }); + + it('Should sugest fields if query string only has spaces', () => { + expect(queryService.shouldSuggestField(' ').suggest).toBeTruthy(); + }); + + it ('Should correctly suggest for field - 1', () => { + expect(queryService.shouldSuggestField( + 'hello: world ' + AND + ' heya' + )).toEqual({ + suggest: true, value: 'heya', lastKey: AND + }); + }); + + it ('Should correctly suggest for field - 2', () => { + expect(queryService.shouldSuggestField( + 'hello: world ' + AND + ' some_key1: some value' + OR + 'some ' + )).toEqual({ + suggest: true, value: 'some', lastKey: OR + }); + }); + + it ('Should correctly suggest for field - 3', () => { + expect(queryService.shouldSuggestField( + 'hello: world ' + OR + ' some_key1: some value' + AND + 'some ' + )).toEqual({ + suggest: true, value: 'some', lastKey: AND + }); + }); + + it ('Should correctly suggest for field - 4', () => { + expect(queryService.shouldSuggestField( + 'hello: world ' + OR + P_START + ' some_key1: some value' + P_END + AND + 'some_key2: some value ' + P_START + )).toEqual({ + suggest: true, value: '', lastKey: P_START + }); + }); + + it ('Should not suggest for field - 1', () => { + expect(queryService.shouldSuggestField( + 'hello: world ' + OR + P_START + ' some_key1: some value' + )).toEqual({ + suggest: false, value: 'some_key1: some value', lastKey: '' + }); + }); + + it ('Should suggest correct value - 1', () => { + expect(queryService.suggestValue( + ' some_key1: some value' + )).toEqual({ + value: 'some value', field: 'some_key1' + }); + }); + + it ('Should suggest correct value - 2', () => { + expect(queryService.suggestValue( + ' some_key1: "some value"' + )).toEqual({ + value: 'some value', field: 'some_key1' + }); + }); + + it ('Should suggest correct value - 3', () => { + expect(queryService.suggestValue( + ' some_key1: "some value' + )).toEqual({ + value: 'some value', field: 'some_key1' + }); + }); + + it ('Should suggest correct value - 4', () => { + expect(queryService.suggestValue( + ' some_key1: "some value1, some valu' + )).toEqual({ + value: 'some valu', field: 'some_key1' + }); + }); + + it ('Should suggest correct value - 5', () => { + expect(queryService.suggestValue( + ' some_key1: ' + )).toEqual({ + value: '', field: 'some_key1' + }); + }); + + it ('Should suggest right field - 0', () => { + queryService.queryObservable.next(''); + queryService.getSuggestions().subscribe((v) => { + expect(v).toEqual(['area.name', 'iteration.name', 'parent.number', 'title', 'assignee', 'label.name']); + }); + }); + + it ('Should suggest right field - 1', () => { + queryService.queryObservable.next('are'); + queryService.getSuggestions().subscribe((v) => { + expect(v).toEqual(['area.name', 'parent.number']); + }); + }); + + it ('Should suggest right field - 2', () => { + queryService.queryObservable.next('area.'); + queryService.getSuggestions().subscribe((v) => { + expect(v).toEqual(['area.name']); + }); + }); + + it ('Should suggest right field - 3', () => { + queryService.queryObservable.next('area.'); + queryService.getSuggestions().subscribe((v) => { + expect(v).toEqual(['area.name']); + }); + }); + + it ('Should suggest right field - 4', () => { + queryService.queryObservable.next('area.name:'); + queryService.getSuggestions().subscribe((v) => { + expect(v).toEqual(['area-1', 'area-2', 'area-23']); + }); + }); + + it ('Should suggest right field - 5', () => { + queryService.queryObservable.next('area.name:area-2'); + queryService.getSuggestions().subscribe((v) => { + expect(v).toEqual(['area-2', 'area-23']); + }); + }); + + it ('Should suggest right field - 6', () => { + queryService.queryObservable.next('area.name:area-2'); + queryService.getSuggestions().subscribe((v) => { + expect(v).toEqual(['area-2', 'area-23']); + }); + }); + + it ('Should replace suggestion correctly - 1', () => { + expect( + queryService.replaceSuggestedValue( + '', '', 'area', 'area.name' + ) + ).toBe('area.name'); + }); + + it ('Should replace suggestion correctly - 2', () => { + expect( + queryService.replaceSuggestedValue( + 'area.name:area', '- 1 $AND iteration:', 'area', 'area - 2' + ) + ).toBe('area.name: area - 2 $AND iteration:'); + }); + + it ('Should replace suggestion correctly - 3', () => { + expect( + queryService.replaceSuggestedValue( + 'area.name:', '- 1 $AND iteration:', '', 'area - 2' + ) + ).toBe('area.name: area - 2 $AND iteration:'); + }); + } +); diff --git a/src/app/services/query-suggestion.service.ts b/src/app/services/query-suggestion.service.ts new file mode 100644 index 000000000..fbc18bc86 --- /dev/null +++ b/src/app/services/query-suggestion.service.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@angular/core'; +import { UserService } from 'ngx-login-client'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; +import { AreaQuery } from '../models/area.model'; +import { IterationQuery } from '../models/iteration.model'; +import { LabelQuery } from '../models/label.model'; +import { + AND, ENCLOSURE, EQUAL_QUERY, + NOT_EQUAL_QUERY, OR, P_START +} from './query-keys'; + +const keys_before_field = [ + AND, OR, P_START +]; +const keys_before_value = [ + EQUAL_QUERY, NOT_EQUAL_QUERY +]; + + +@Injectable() +export class QuerySuggestionService { + public queryObservable: BehaviorSubject = new BehaviorSubject('-'); + private valueToSuggest: string = ''; + private valueToSuggestObservable: BehaviorSubject = new BehaviorSubject('-'); + constructor( + private areaQuery: AreaQuery, + private iterationQuery: IterationQuery, + private userService: UserService, + private labelQuery: LabelQuery + ) {} + + private fields = of({ + 'area.name': this.areaQuery.getAreaNames(), + 'iteration.name': this.iterationQuery.getIterationNames, + 'parent.number': of([]), + 'title': of([]), + 'assignee': this.fetchUsersBySearchValue(), + 'label.name': this.labelQuery.getlabelNames + }); + + fetchUsersBySearchValue() { + return this.valueToSuggestObservable.pipe( + debounceTime(500), + switchMap(value => this.userService.getUsersBySearchString(value)), + map(users => users.map(user => user.attributes.username)) + ); + } + + /** + * Takes the entire query written so far + * split the query by keywords for the + * ease of processing + * @param query + */ + shouldSuggestField(query: string): + {suggest: boolean; value: string; lastKey: string} { + let output = { suggest: true, value: query.trim(), lastKey: '' }; + + for (let i = 0; i < keys_before_field.length; i++) { + let temp = output.value.split(keys_before_field[i]); + if (temp.length > 1) { + // split with the key and take the last value from the array + output = { + suggest: true, + value: temp[temp.length - 1].trim(), + lastKey: keys_before_field[i] + }; + } + } + + if (output.value.indexOf(EQUAL_QUERY) > -1 || + output.value.indexOf(NOT_EQUAL_QUERY) > -1) { + output = { + suggest: false, + value: output.value, + lastKey: '' + }; + } + + return output; + } + + /** + * Takes a chunk of the query + * extract the field and typed value + * @param query + */ + suggestValue(query: string): { field: string; value: string; } { + // split with equal + let splitField = query.split(EQUAL_QUERY); + // if no luck split with not equal + if (splitField.length === 1) { + splitField = query.split(NOT_EQUAL_QUERY); + } + const field = splitField[0].trim(); + // could be a IN query so we split it by , + let splittedvalues = splitField[1].split(','); + // take the last one from the array + let value = splittedvalues[splittedvalues.length - 1].trim(); + // trim the value if it has enclouser + if (value[0] == ENCLOSURE) { + value = value.substr(1); + } + if (value[value.length - 1] == ENCLOSURE) { + value = value.substr(0, value.length - 1); + } + return {field, value}; + } + + replaceSuggestion(query_before_cursor: string, query_after_cursor: string, suggestion: string): string { + return this.replaceSuggestedValue( + query_before_cursor, query_after_cursor, this.valueToSuggest, suggestion + ); + } + + /** + * This function takes the query and the suggested value to be replaced + * the value is what is typed so far or before the cursor after the last key + * for example, if the query before cursor is "area.name: area - 1 $AND iteration" + * and after cursor is ".name: iteration - 1" + * the value should be "iteration" and we'll have to remove that part from + * query before cursor + * For query after cursor section, we have to detect the first occurance of any key + * and remove till that from initial of that part. + * For our example the first key is ":" and we have to remove ".name" from that section + * @param query_before_cursor + * @param query_after_cursor + * @param value + * @param suggestion + */ + replaceSuggestedValue(query_before_cursor: string, query_after_cursor: string, value: string, suggestion: string): string { + const all_keys = [...keys_before_field, ...keys_before_value]; + const first_part = value == '' ? query_before_cursor : + query_before_cursor.substr(0, query_before_cursor.length - value.length).trim(); + let after_cursor = query_after_cursor; + all_keys.forEach(key => { + if (after_cursor !== '') { + after_cursor = after_cursor.split(key)[0]; + } + }); + const last_part = query_after_cursor.substr(after_cursor.length); + return `${first_part} ${suggestion} ${last_part}`.trim(); + } + + getSuggestions(): Observable { + return this.queryObservable + .pipe( + distinctUntilChanged(), + switchMap(query => { + if (query === '-') { return of([]); } + const fieldSuggest = this.shouldSuggestField(query); + if (fieldSuggest.suggest) { + this.valueToSuggest = fieldSuggest.value; + this.valueToSuggestObservable.next(fieldSuggest.value); + return this.fields.pipe( + map(fields => Object.keys(fields) + .filter(f => f.indexOf(fieldSuggest.value) > -1) + ) + ); + } else { + const fieldValue = this.suggestValue(fieldSuggest.value); + this.valueToSuggest = fieldValue.value; + this.valueToSuggestObservable.next(fieldValue.value); + return this.fields.pipe( + switchMap(fields => { + if (fields[fieldValue.field]) { + return fields[fieldValue.field].pipe( + map((values: string[]) => + values.filter(v => v.indexOf(fieldValue.value) > -1) + ) + ); + } else { + return of([]); + } + }) + ); + } + }) + ); + } +} diff --git a/src/app/widgets/list-item/list-item.component.html b/src/app/widgets/list-item/list-item.component.html new file mode 100644 index 000000000..13bcdfd25 --- /dev/null +++ b/src/app/widgets/list-item/list-item.component.html @@ -0,0 +1,5 @@ +
  • + +
  • \ No newline at end of file diff --git a/src/app/widgets/list-item/list-item.component.less b/src/app/widgets/list-item/list-item.component.less new file mode 100644 index 000000000..f30018307 --- /dev/null +++ b/src/app/widgets/list-item/list-item.component.less @@ -0,0 +1,16 @@ +@import (reference) '../../../assets/stylesheets/_base'; + +.li-dropdown-item { + padding: 1px 10px; + &.active { + background-color: @color-pf-blue-50; + border-color: @color-pf-blue-100; + } + &.disabled { + opacity: .3; + } + &:hover { + background-color: @color-pf-blue-50; + border-color: @color-pf-blue-100; + } +} diff --git a/src/app/widgets/list-item/list-item.component.ts b/src/app/widgets/list-item/list-item.component.ts new file mode 100644 index 000000000..6b2ca21ed --- /dev/null +++ b/src/app/widgets/list-item/list-item.component.ts @@ -0,0 +1,26 @@ +import { Highlightable } from '@angular/cdk/a11y'; +import { Component, HostBinding, Input, OnInit, ViewChildren } from '@angular/core'; + +@Component({ + selector: 'list-item', + templateUrl: './list-item.component.html', + styleUrls: ['./list-item.component.less'] +}) +export class ListItemComponent implements Highlightable { + @Input() item; + @Input() disabled = false; + private _isActive = false; + + setActiveStyles() { + this._isActive = true; + } + + setInactiveStyles() { + this._isActive = false; + } + + getLabel() { + return this.item; + } + +} diff --git a/src/app/widgets/list-item/list-item.module.ts b/src/app/widgets/list-item/list-item.module.ts new file mode 100644 index 000000000..2f16e1932 --- /dev/null +++ b/src/app/widgets/list-item/list-item.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; +import { ListItemComponent } from './list-item.component'; + +@NgModule({ + declarations: [ListItemComponent], + exports: [ListItemComponent] +}) + +export class ListItemModule {} diff --git a/src/tests/specs/agileTemplate.spec.ts b/src/tests/specs/agileTemplate.spec.ts index 8fe55deb3..3683ca99f 100644 --- a/src/tests/specs/agileTemplate.spec.ts +++ b/src/tests/specs/agileTemplate.spec.ts @@ -11,7 +11,7 @@ describe('Agile template tests: ', () => { await support.desktopTestSetup(); plannerAgile = new PlannerPage(browser.baseUrl); plannerAgile.openInBrowser(); - await plannerAgile.waitUntilUrlContains('typegroup.name:Work'); + await plannerAgile.waitUntilUrlContains('typegroup.name'); }); beforeEach(async () => {