diff --git a/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-component.scss index 62c964d2daa..fce80b8f03d 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-component.scss @@ -107,12 +107,12 @@ @extend %filter-tree__expression-item !optional; } - @include e(expression-item-ghost) { + @include e(expression-item-drop-ghost) { @extend %filter-tree__expression-item-ghost !optional; } - @include e(expression-item-drop-ghost) { - @extend %filter-tree__expression-drop-item-ghost !optional; + @include e(expression-item-keyboard-ghost) { + @extend %filter-tree__expression-item-keyboard-ghost !optional; } @include e(expression-column) { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-theme.scss index ca2358f1abb..98c2167bf4a 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/query-builder/_query-builder-theme.scss @@ -480,18 +480,25 @@ %filter-tree__expression-item-ghost { - &.igx-chip__item { + .igx-chip__item { @include type-style('body-2'); --ig-body-2-text-transform: unset; padding-inline: rem(32px); + + color: color($color: 'gray', $variant: if($theme-variant == 'light', 600, 900)); + border: rem(1px) dashed color($color: 'gray', $variant: if($theme-variant == 'light', 600, 300)); + background: transparent; } - - color: color($color: 'gray', $variant: if($theme-variant == 'light', 600, 900)); - border: rem(1px) dashed color($color: 'gray', $variant: if($theme-variant == 'light', 600, 300)); - background: transparent; } + + %filter-tree__expression-item-keyboard-ghost { + .igx-chip__item { + box-shadow: var(--ghost-shadow); + background: var(--ghost-background); + } + } %filter-tree__expression-column { padding: 0 rem(8px); diff --git a/projects/igniteui-angular/src/lib/query-builder/query-builder-drag.service.ts b/projects/igniteui-angular/src/lib/query-builder/query-builder-drag.service.ts index 9866fa83f00..31ae3fcf180 100644 --- a/projects/igniteui-angular/src/lib/query-builder/query-builder-drag.service.ts +++ b/projects/igniteui-angular/src/lib/query-builder/query-builder-drag.service.ts @@ -1,50 +1,50 @@ -import { filter, fromEvent, sampleTime, Subscription, tap } from 'rxjs'; +import { filter, fromEvent, sampleTime, Subscription } from 'rxjs'; import { IgxQueryBuilderTreeComponent } from './query-builder-tree.component'; import { ElementRef, Injectable } from '@angular/core'; -import { ExpressionGroupItem, ExpressionItem, QueryBuilderSelectors } from './query-builder.common'; +import { ExpressionGroupItem, ExpressionItem, ExpressionOperandItem, QueryBuilderSelectors } from './query-builder.common'; +import { IgxChipComponent } from '../chips/chip.component'; const DEFAULT_SET_Z_INDEX_DELAY = 10; const Z_INDEX_TO_SET = 10010; //overlay z-index is 10005 /** @hidden @internal */ @Injectable() -export class IgxQueryBuilderDragService { - public dropGhostChipNode: Node; - private sourceExpressionItem: ExpressionItem; - private sourceElement: HTMLElement; - private targetExpressionItem: ExpressionItem; - private targetElement: HTMLElement; - private dropUnder: boolean; +export class IgxQueryBuilderDragService { + + /** The ExpressionItem that's actually the drop ghost's content */ + public dropGhostExpression: ExpressionItem; + public isKeyboardDrag: boolean; private _queryBuilderTreeComponent: IgxQueryBuilderTreeComponent; private _queryBuilderTreeComponentElRef: ElementRef; + private _sourceExpressionItem: ExpressionItem; + private _sourceElement: HTMLElement; + private _targetExpressionItem: ExpressionItem; + private _dropUnder: boolean; private _ghostChipMousemoveSubscription$: Subscription; private _keyboardSubscription$: Subscription; - private _keyDragOffsetIndex: number = 0; - private _keyDragFirstMove: boolean = true; - private _isKeyboardDrag: boolean; - /** stores a flat ordered list of all chips, including +Condition button, while performing the keyboard drag&drop */ - private _dropZonesList: HTMLElement[]; - /** stores a flat ordered list of all expressions, including +Condition button, while performing the keyboard drag&drop */ - private _expressionsList: ExpressionItem[]; + private _keyDragCurrentIndex: number = 0; + private _keyDragInitialIndex: number = 0; + private _isKeyDragsFirstMove: boolean = true; + /** Stores a flat ordered list of possible drop locations as Tuple <[targetExpression, dropUnder]>, while performing the keyboard drag&drop */ + private _possibleDropLocations: Array<[ExpressionItem, boolean]>; private _timeoutId: any; - /** Get the dragged ghost as a HTMLElement*/ - private get dragGhostElement(): HTMLElement { - return (document.querySelector('.igx-chip__ghost[ghostclass="igx-chip__ghost"]') as HTMLElement); + private get getDragGhostElement(): HTMLElement { + return (document.querySelector(`.${QueryBuilderSelectors.CHIP_GHOST}[ghostclass="${QueryBuilderSelectors.CHIP_GHOST}"]`) as HTMLElement); } - /** Get the drop ghost as a HTMLElement*/ - private get dropGhostElement(): HTMLElement { - return (document.querySelector(`.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST}`) as HTMLElement); + /** Get the drop ghost chip component */ + private get getDropGhostElement(): IgxChipComponent { + return this._queryBuilderTreeComponent.expressionsChips.find(x => x.data === this.dropGhostExpression); } - private get mainExpressionTree(): HTMLElement { + private get getMainExpressionTree(): HTMLElement { return this._queryBuilderTreeComponentElRef.nativeElement.querySelector(`.${QueryBuilderSelectors.FILTER_TREE}`); } - + public register(tree: IgxQueryBuilderTreeComponent, el: ElementRef) { this._queryBuilderTreeComponent = tree; this._queryBuilderTreeComponentElRef = el; @@ -58,26 +58,27 @@ export class IgxQueryBuilderDragService { * */ public onMoveStart(sourceDragElement: HTMLElement, sourceExpressionItem: ExpressionItem, isKeyboardDrag: boolean): void { - //console.log('Picked up:', event, sourceDragElement); this.resetDragAndDrop(true); - this._isKeyboardDrag = isKeyboardDrag; - this.sourceExpressionItem = sourceExpressionItem; - this.sourceElement = sourceDragElement; + this._queryBuilderTreeComponent._expressionTreeCopy = this._queryBuilderTreeComponent._expressionTree; + this.isKeyboardDrag = isKeyboardDrag; + this._sourceExpressionItem = sourceExpressionItem; + this._sourceElement = sourceDragElement; this.listenToKeyboard(); - if (!this._isKeyboardDrag) { - this.sourceElement.style.display = 'none'; + if (!this.isKeyboardDrag) { + this._sourceElement.style.display = 'none'; this.setDragGhostZIndex(); } } /** When dragged chip is let go outside a proper drop zone */ public onMoveEnd(): void { - // console.log('Let go:'); - if (!this.sourceElement || !this.sourceExpressionItem) return; + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } - if (this.dropGhostChipNode) { + if (this.dropGhostExpression) { //If there is a ghost chip presented to the user, execute drop this.onChipDropped(); } else { @@ -92,33 +93,35 @@ export class IgxQueryBuilderDragService { * @param targetDragElement The HTML element of the drop area chip that's been dragged to * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to */ - public onChipEnter(targetDragElement: HTMLElement, targetExpressionItem: ExpressionItem): void { - // console.log('Entering:', targetDragElement, targetExpressionItem); - if (!this.sourceElement || !this.sourceExpressionItem) return; + public onChipEnter(targetDragElement: HTMLElement, targetExpressionItem: ExpressionItem) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } - //If entering the one that's been picked up - if (targetDragElement == this.sourceElement) return; + //If entering the one that's been picked up don't do any thing + if (targetExpressionItem === this.dropGhostExpression) { + return; + } //Simulate leaving the last entered chip in case of no Leave event triggered due to the artificial drop zone of a north positioned ghost chip - if (this.targetElement) { + if (this._targetExpressionItem) { this.resetDragAndDrop(false); } - this.targetElement = targetDragElement; - this.targetExpressionItem = targetExpressionItem; + this._targetExpressionItem = targetExpressionItem; //Determine the middle point of the chip. const appendUnder = this.ghostInLowerPart(targetDragElement); - this.renderDropGhostChip(targetDragElement, appendUnder); + this.renderDropGhostChip(appendUnder); } /** When mouse drag moves in a div's drop area * @param targetDragElement The HTML element of the drop area chip that's been dragged to * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to */ - public onDivOver(targetDragElement: HTMLElement, targetExpressionItem: ExpressionItem): void { - if (this.targetExpressionItem === targetExpressionItem) { + public onDivOver(targetDragElement: HTMLElement, targetExpressionItem: ExpressionItem) { + if (this._targetExpressionItem === targetExpressionItem) { this.onChipOver(targetDragElement) } else { this.onChipEnter(targetDragElement, targetExpressionItem); @@ -129,26 +132,28 @@ export class IgxQueryBuilderDragService { * @param targetDragElement The HTML element of the drop area chip that's been dragged to */ public onChipOver(targetDragElement: HTMLElement): void { - //console.log('Over:', targetDragElement, 'type: ', typeof event); - if (!this.sourceElement || !this.sourceExpressionItem) return; + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } //Determine the middle point of the chip. const appendUnder = this.ghostInLowerPart(targetDragElement); - this.renderDropGhostChip(targetDragElement, appendUnder); + this.renderDropGhostChip(appendUnder); } /** When mouse drag leaves a chip's drop area */ - public onChipLeave(): void { - if (!this.sourceElement || !this.sourceExpressionItem || !this.targetElement) return; - //console.log('Leaving:', targetDragElement.textContent.trim()); + public onChipLeave() { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } //if the drag ghost is on the drop ghost row don't trigger leave - if (this.dragGhostIsOnDropGhostRow(this.dragGhostElement, this.dropGhostChipNode?.firstChild as HTMLElement)) { + if (this.dragGhostIsOnDropGhostRow()) { return; } - if (this.targetElement) { + if (this._targetExpressionItem) { this.resetDragAndDrop(false) } } @@ -156,20 +161,24 @@ export class IgxQueryBuilderDragService { /** When dragged chip is let go in div's drop area * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to */ - public onDivDropped(targetExpressionItem: ExpressionItem): void { - if (targetExpressionItem != this.sourceExpressionItem) { + public onDivDropped(targetExpressionItem: ExpressionItem) { + if (targetExpressionItem !== this._sourceExpressionItem) { this.onChipDropped(); } } /** When dragged chip is let go in chip's drop area */ - public onChipDropped(): void { - if (!this.sourceElement || !this.sourceExpressionItem || !this.targetElement) return; - //console.log('Move: [', this.sourceElement.children[0].textContent.trim(), (this.dropUnder ? '] under: [' : '] over:'), this.targetExpressionItem) + public onChipDropped() { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } - const dropLocationIndex = this.calculateDropLocationIndex(this.targetExpressionItem, this.sourceExpressionItem, this.dropUnder); + //Determine which chip to be focused after drop completes + const [dropLocationIndex, _] = this.countChipsBeforeDropLocation(this._queryBuilderTreeComponent.rootGroup); - this.moveDraggedChipToNewLocation(this.sourceExpressionItem, this.targetExpressionItem, this.dropUnder); + //Delete from old place + this._queryBuilderTreeComponent.deleteItem(this._sourceExpressionItem); + this.dropGhostExpression = null; this._queryBuilderTreeComponent.focusChipAfterDrag(dropLocationIndex); @@ -182,28 +191,30 @@ export class IgxQueryBuilderDragService { * @param targetDragElement The HTML element of the drop area chip that's been dragged to * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to */ - public onGroupRootOver(targetDragElement: HTMLElement, targetExpressionItem: ExpressionGroupItem): void { - //console.log('Entering:', targetDragElement, targetExpressionItem); - if (!this.sourceElement || !this.sourceExpressionItem) return; + public onGroupRootOver(targetDragElement: HTMLElement, targetExpressionItem: ExpressionGroupItem) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } - let newTargetElement, newTargetExpressionItem; + let newTargetExpressionItem; if (this.ghostInLowerPart(targetDragElement) || !targetExpressionItem.parent) { - //if ghost in lower part of the AND/OR (or it's the main group) => drop before the group starts - newTargetElement = targetDragElement.nextElementSibling.firstElementChild; - newTargetElement = (newTargetElement.className.indexOf(QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST) !== -1) ? newTargetElement.nextElementSibling : newTargetElement; - newTargetExpressionItem = targetExpressionItem.children[0]; + //if ghost is in lower part of the AND/OR (or it's the main group) => drop as first child of that group + //accounting for the fact that the drop ghost might already be there as first child + if (targetExpressionItem.children[0] !== this.dropGhostExpression) { + newTargetExpressionItem = targetExpressionItem.children[0]; + } else { + newTargetExpressionItem = targetExpressionItem.children[1]; + } } else { - //if ghost in upper part or it's the root group => drop as first child of that group - newTargetElement = targetDragElement.parentElement.parentElement; + //if ghost is in upper part => drop before the group starts newTargetExpressionItem = targetExpressionItem; } - if (newTargetElement && (this.targetElement !== newTargetElement || this.targetExpressionItem !== newTargetExpressionItem)) { + if (this._targetExpressionItem !== newTargetExpressionItem) { this.resetDragAndDrop(false); - this.targetElement = newTargetElement; - this.targetExpressionItem = newTargetExpressionItem; - this.renderDropGhostChip(this.targetElement, false); + this._targetExpressionItem = newTargetExpressionItem; + this.renderDropGhostChip(false); } } @@ -211,12 +222,11 @@ export class IgxQueryBuilderDragService { * @param addConditionElement The Add condition button HTML Element * @param rootGroup The root group of the query tree */ - public onAddConditionEnter(addConditionElement: HTMLElement, rootGroup: ExpressionGroupItem): void { - //console.log('onAddConditionEnter', addConditionElement); - if (!this.sourceElement || !this.sourceExpressionItem) return; - + public onAddConditionEnter(addConditionElement: HTMLElement, rootGroup: ExpressionGroupItem) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } const lastElement = addConditionElement.parentElement.previousElementSibling.lastElementChild; - if (lastElement == this.dropGhostChipNode) return; //simulate entering in the lower part of the last chip/group this.onChipEnter(lastElement as HTMLElement, rootGroup.children[rootGroup.children.length - 1]); @@ -228,21 +238,24 @@ export class IgxQueryBuilderDragService { * @param sourceExpressionItem The expressionItem of the chip that's been dragged * */ - public onChipDragIndicatorFocus(sourceDragElement: HTMLElement, sourceExpressionItem: ExpressionItem): void { - this.onMoveStart(sourceDragElement, sourceExpressionItem, true); + public onChipDragIndicatorFocus(sourceDragElement: HTMLElement, sourceExpressionItem: ExpressionItem) { + //if drag is not underway, already + if (!this.getDropGhostElement) { + this.onMoveStart(sourceDragElement, sourceExpressionItem, true); + } } /** When chip's drag indicator looses focus*/ - public onChipDragIndicatorFocusOut(): void { - if (this.sourceElement?.style?.display !== 'none') { + public onChipDragIndicatorFocusOut() { + if (this._sourceElement?.style?.display !== 'none') { this.resetDragAndDrop(true); this._keyboardSubscription$?.unsubscribe(); } } /** Upon blurring the tree, if Keyboard drag is underway and the next active item is not the drop ghost's drag indicator icon, cancel the drag&drop procedure*/ - public onDragFocusOut(): void { - if (this._isKeyboardDrag && this.dropGhostElement) { + public onDragFocusOut() { + if (this.isKeyboardDrag && this.getDropGhostElement) { //have to wait a tick because upon blur, the next activeElement is always body, right before the next element gains focus setTimeout(() => { if (document.activeElement.className.indexOf(QueryBuilderSelectors.DRAG_INDICATOR) === -1) { @@ -254,80 +267,64 @@ export class IgxQueryBuilderDragService { } /** Checks if the dragged ghost is horizontally on the same line with the drop ghost*/ - private dragGhostIsOnDropGhostRow(dragGhost: HTMLElement, dropGhost: HTMLElement): boolean { - const dragGhostBounds = dragGhost.getBoundingClientRect(); - const dropGhostBounds = dropGhost.getBoundingClientRect(); + private dragGhostIsOnDropGhostRow() { + const dragGhostBounds = this.getDragGhostElement.getBoundingClientRect(); - if (!dragGhostBounds || !dropGhostBounds) return false; + const dropGhostBounds = this.getDropGhostElement?.nativeElement?.parentElement.getBoundingClientRect(); - const ghostHeight = dragGhostBounds.bottom - dragGhostBounds.top; + if (!dragGhostBounds || !dropGhostBounds) { + return false; + } + + const tolerance = dragGhostBounds.bottom - dragGhostBounds.top; - return !(dragGhostBounds.bottom < dropGhostBounds.top - ghostHeight || dragGhostBounds.top > dropGhostBounds.bottom + ghostHeight); + return !(dragGhostBounds.bottom < dropGhostBounds.top - tolerance || dragGhostBounds.top > dropGhostBounds.bottom + tolerance); } /** Checks if the dragged ghost is north or south of a target element's center*/ - private ghostInLowerPart(ofElement: HTMLElement): boolean { - //if (event == null) return true; - const ghostBounds = this.dragGhostElement.getBoundingClientRect(); + private ghostInLowerPart(ofElement: HTMLElement) { + const ghostBounds = this.getDragGhostElement.getBoundingClientRect(); const targetBounds = ofElement.getBoundingClientRect(); return ((ghostBounds.top + ghostBounds.bottom) / 2) >= ((targetBounds.top + targetBounds.bottom) / 2); } - /** Create the drop ghost node based on the base chip that's been dragged*/ - private createDropGhost(keyboardMode?: boolean): Node { - const dragCopy = this.sourceElement.cloneNode(true); - (dragCopy as HTMLElement).classList.add(QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST); - (dragCopy as HTMLElement).style.display = ''; - (dragCopy.firstChild as HTMLElement).style.visibility = 'visible'; - dragCopy.removeChild(dragCopy.childNodes[3]); - - if (!keyboardMode) { - var span = document.createElement('span') - span.innerHTML = this._queryBuilderTreeComponent.resourceStrings.igx_query_builder_drop_ghost_text; - - dragCopy.firstChild.firstChild.removeChild(dragCopy.firstChild.firstChild.childNodes[1]); - dragCopy.firstChild.firstChild.removeChild(dragCopy.firstChild.firstChild.childNodes[1]); - (dragCopy.firstChild.firstChild.firstChild as HTMLElement).replaceChildren(span); - (dragCopy.firstChild.firstChild as HTMLElement).classList.add(QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_GHOST); - } else { - (dragCopy.firstChild.firstChild as HTMLElement).classList.add(QueryBuilderSelectors.CHIP_GHOST); - } - return dragCopy; - } + /** Make a copy of the _sourceExpressionItem's chip and paste it in the tree north or south of the _targetExpressionItem's chip */ + private renderDropGhostChip(appendUnder: boolean): void { + if (appendUnder !== this._dropUnder || this.isKeyboardDrag) { + this.clearDropGhost(); + + //Copy dragged chip + const dragCopy = { ...this._sourceExpressionItem }; + dragCopy.parent = this._targetExpressionItem.parent; + this.dropGhostExpression = dragCopy; - /** Make a copy of the drag chip and place it in the DOM north or south of the drop chip*/ - private renderDropGhostChip(appendToElement: HTMLElement, appendUnder: boolean, keyboardMode?: boolean): void { - const dragCopy = this.createDropGhost(keyboardMode); - - //Append the ghost - if ((!appendUnder && this.dropUnder !== false) || //mouse mode - (keyboardMode && !appendUnder)) { - //over - (this.dropGhostChipNode as HTMLElement)?.remove(); - this.dropGhostChipNode = dragCopy; - this.dropUnder = false; - appendToElement.parentNode.insertBefore(this.dropGhostChipNode, appendToElement); - } else if ((appendUnder && this.dropUnder !== true) || //mouse mode - (keyboardMode && appendUnder)) { - //under - (this.dropGhostChipNode as HTMLElement)?.remove(); - this.dropGhostChipNode = dragCopy; - this.dropUnder = true; - appendToElement.parentNode.insertBefore(this.dropGhostChipNode, appendToElement.nextElementSibling); + //Paste chip + this._dropUnder = appendUnder; + const pasteIndex = this._targetExpressionItem.parent.children.indexOf(this._targetExpressionItem); + this._targetExpressionItem.parent.children.splice(pasteIndex + (this._dropUnder ? 1 : 0), 0, dragCopy); } //Put focus on the drag icon of the ghost while performing keyboard drag - if (this._isKeyboardDrag) { - ((this.dropGhostChipNode as HTMLElement).querySelector(`.${QueryBuilderSelectors.DRAG_INDICATOR}`) as HTMLElement).focus(); + if (this.isKeyboardDrag) { + setTimeout(() => { // this will make the execution after the drop ghost is rendered + const dropGhostDragIndicator = this.getDropGhostElement?.nativeElement?.querySelector(`.${QueryBuilderSelectors.DRAG_INDICATOR}`) as HTMLElement; + if (dropGhostDragIndicator) { + dropGhostDragIndicator.focus(); + } + }, 0); } //Attach a mousemove event listener (if not already in place) to the dragged ghost (if present) - if (this.dragGhostElement && (!this._ghostChipMousemoveSubscription$ || this._ghostChipMousemoveSubscription$?.closed === true)) { - const mouseMoves = fromEvent(this.dragGhostElement, 'mousemove'); + if (!this.isKeyboardDrag && this.getDragGhostElement && (!this._ghostChipMousemoveSubscription$ || this._ghostChipMousemoveSubscription$?.closed === true)) { + const mouseMoves = fromEvent(this.getDragGhostElement, 'mousemove'); + //When mouse moves and there is a drop ghost => trigger onChipLeave to check if the drop ghost has to be removed + //effectively solving the case when mouse leaves the QB and a drop ghost is still in place this._ghostChipMousemoveSubscription$ = mouseMoves.pipe(sampleTime(100)).subscribe(() => { - this.onChipLeave(); + if (this.getDropGhostElement) { + this.onChipLeave(); + } }); } @@ -335,69 +332,71 @@ export class IgxQueryBuilderDragService { } /** Set the cursor when dragging a ghost*/ - private setDragCursor(cursor: string): void { - if (this.dragGhostElement) { - this.dragGhostElement.style.cursor = cursor; + private setDragCursor(cursor: string) { + if (this.getDragGhostElement) { + this.getDragGhostElement.style.cursor = cursor; } } - /** Execute the drop*/ - private moveDraggedChipToNewLocation(sourceExpressionItem: ExpressionItem, appendToExpressionItem: ExpressionItem, dropUnder: boolean): void { - //Copy dragged chip - const dragCopy = { ...sourceExpressionItem }; - dragCopy.parent = appendToExpressionItem.parent; - - //Paste on new place - const index = appendToExpressionItem.parent.children.indexOf(appendToExpressionItem); - appendToExpressionItem.parent.children.splice(index + (dropUnder ? 1 : 0), 0, dragCopy); - - //Delete from old place - this._queryBuilderTreeComponent.deleteItem(sourceExpressionItem); + /** Removes the drop ghost expression from the tree and it's chip effectively */ + private clearDropGhost() { + if (this.dropGhostExpression) { + const children = this.dropGhostExpression.parent.children; + const delIndex = children.indexOf(this.dropGhostExpression); + children.splice(delIndex, 1); + this.dropGhostExpression = null; + } } /** Reset Drag&Drop vars. Optionally the drag source vars too*/ - private resetDragAndDrop(clearDragged: boolean): void { - this.targetExpressionItem = null; - this.targetElement = null; - this.dropUnder = null; - (this.dropGhostChipNode as HTMLElement)?.remove(); - this.dropGhostChipNode = null; - this._keyDragOffsetIndex = 0; - this._keyDragFirstMove = true; + private resetDragAndDrop(clearDragged: boolean) { + this._targetExpressionItem = null; + this._dropUnder = null; + this.clearDropGhost(); + this._keyDragInitialIndex = 0; + this._keyDragCurrentIndex = 0; + this._possibleDropLocations = null; + this._isKeyDragsFirstMove = true; this.setDragCursor('no-drop'); - if ((clearDragged || this._isKeyboardDrag) && this.sourceElement) { - this.sourceElement.style.display = ''; + if (this._queryBuilderTreeComponent._expressionTreeCopy) { + this._queryBuilderTreeComponent._expressionTree = this._queryBuilderTreeComponent._expressionTreeCopy; + } + + if ((clearDragged || this.isKeyboardDrag) && this._sourceElement) { + this._sourceElement.style.display = ''; } if (clearDragged) { - this.sourceExpressionItem = null; - this.sourceElement = null; - this._dropZonesList = null; - this._expressionsList = null; + this._queryBuilderTreeComponent._expressionTreeCopy = null; + this._sourceExpressionItem = null; + this._sourceElement = null; } } - private listenToKeyboard(): void { + /** Start listening for drag and drop specific keys */ + private listenToKeyboard() { this._keyboardSubscription$?.unsubscribe(); - this._keyboardSubscription$ = fromEvent(this.mainExpressionTree, 'keydown') + this._keyboardSubscription$ = fromEvent(this.getMainExpressionTree, 'keydown') .pipe(filter(e => ['ArrowUp', 'ArrowDown', 'Enter', 'Space', 'Escape', 'Tab'].includes(e.key))) - .pipe(tap(e => { - //Inhibit Tabs if keyboard drag is underway - if (e.key !== 'Tab' || this.dropGhostElement) e.preventDefault(); - })) + // .pipe(tap(e => { + // //Inhibit Tabs if keyboard drag is underway (don't allow to loose focus of the drop ghost's drag indicator) + // if (e.key === 'Tab' && this.getDropGhostElement) { + // e.preventDefault(); + // } + // })) .pipe(filter(event => !event.repeat)) .subscribe(e => { - if (e.key == 'Escape') { - //TODO cancel mouse drag + if (e.key === 'Escape') { + //TODO cancel mouse drag once it's implemented in igx-chip draggable this.resetDragAndDrop(false); //Regain focus on the drag icon after keyboard drag cancel - if (this._isKeyboardDrag) { - (this.sourceElement.firstElementChild.firstElementChild.firstElementChild.firstElementChild as HTMLElement).focus(); + if (this.isKeyboardDrag) { + (this._sourceElement.firstElementChild.firstElementChild.firstElementChild.firstElementChild as HTMLElement).focus(); } - } else if (e.key == 'ArrowUp' || e.key == 'ArrowDown') { + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { this.arrowDrag(e.key); - } else if (e.key == 'Enter' || e.key == 'Space') { + } else if (e.key === 'Enter' || e.key === 'Space') { //this.platform.isActivationKey(eventArgs) Maybe use this rather that Enter/Space? this.onChipDropped(); this._keyboardSubscription$.unsubscribe(); @@ -406,194 +405,135 @@ export class IgxQueryBuilderDragService { } /** Perform up/down movement of drop ghost along the expression tree*/ - private arrowDrag(key: string): void { - if (!this.sourceElement || !this.sourceExpressionItem) return; - - if (this._keyDragFirstMove) { - this._expressionsList = this.getListedExpressions(this._queryBuilderTreeComponent.rootGroup); - this._dropZonesList = this.getListedDropZones(); - this.sourceElement.style.display = 'none'; + private arrowDrag(key: string) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; } - //const index = this.expressionsList.indexOf(this.sourceExpressionItem); - const index = this._dropZonesList.indexOf(this.sourceElement); + const rootGroup = this._queryBuilderTreeComponent.rootGroup; - if (index === -1) console.error("Dragged expression not found"); + if (this._isKeyDragsFirstMove) { + this._possibleDropLocations = this.getPossibleDropLocations(rootGroup, true); + this._keyDragInitialIndex = this._possibleDropLocations.findIndex(e => e[0] === this._sourceExpressionItem && e[1] === true); + this._keyDragCurrentIndex = this._keyDragInitialIndex; + if (this._keyDragInitialIndex === -1) { + console.error("Dragged expression not found"); + } + this._sourceElement.style.display = 'none'; + } - let newKeyIndexOffset = 0; - if (key == 'ArrowUp') { - //decrease index offset capped at top of tree - newKeyIndexOffset = this._keyDragOffsetIndex - 1 >= index * -2 - 1 ? this._keyDragOffsetIndex - 1 : this._keyDragOffsetIndex; - } else if (key == 'ArrowDown') { - //increase index offset capped at bottom of tree - newKeyIndexOffset = this._keyDragOffsetIndex + 1 <= (this._dropZonesList.length - 2 - index) * 2 + 2 ? this._keyDragOffsetIndex + 1 : this._keyDragOffsetIndex; + let newKeyIndexOffset = this._keyDragCurrentIndex; + if (key === 'ArrowUp') { + //decrease index capped at top of tree + newKeyIndexOffset && newKeyIndexOffset--; + } else if (key === 'ArrowDown') { + //increase index capped at bottom of tree + newKeyIndexOffset < this._possibleDropLocations.length - 1 && newKeyIndexOffset++; } else { console.error('wrong key'); return; } - //if up/down limits not reached - if (newKeyIndexOffset != this._keyDragOffsetIndex) { - this._keyDragOffsetIndex = newKeyIndexOffset; - const indexOffset = ~~(this._keyDragOffsetIndex / 2); - - if (index + indexOffset <= this._expressionsList.length - 1) { - let under = this._keyDragOffsetIndex < 0 ? this._keyDragOffsetIndex % 2 == 0 ? true : false : this._keyDragOffsetIndex % 2 == 0 ? false : true; - - if (this._dropZonesList[index + indexOffset].className.indexOf(QueryBuilderSelectors.FILTER_TREE_EXPRESSION_CONTEXT_MENU) === -1) { - this.targetElement = this._dropZonesList[index + indexOffset] - this.targetExpressionItem = this._expressionsList[index + indexOffset]; - } else { - //if the current drop zone is a group root (AND/OR) - if (index + indexOffset === 0) { - //If the root group's AND/OR - this.targetElement = this._dropZonesList[0] - this.targetExpressionItem = this._queryBuilderTreeComponent.rootGroup.children[0]; - under = true; - } else if (under) { - //If under AND/OR - this.targetElement = this._dropZonesList[index + indexOffset] - this.targetExpressionItem = this._expressionsList[index + indexOffset + 1]; - } else { - //if over AND/OR - this.targetElement = this._dropZonesList[index + indexOffset].parentElement.parentElement; - this.targetExpressionItem = this._expressionsList[index + indexOffset]; - } + //if drop location has no change + if (newKeyIndexOffset !== this._keyDragCurrentIndex || this._isKeyDragsFirstMove) { + this._keyDragCurrentIndex = newKeyIndexOffset; - //If should drop under AND/OR => drop over first chip in that AND/OR's group - if (under) { - this.targetElement = this.targetElement.nextElementSibling.firstElementChild as HTMLElement; - if (this.targetElement === this.dropGhostChipNode) this.targetElement = this.targetElement.nextElementSibling as HTMLElement; - under = false; - } - } - const before = this.getPreviousChip(this.dropGhostElement); - const after = this.getNextChip(this.dropGhostElement); - - this.renderDropGhostChip(this.targetElement, under, true); - - //If it's the first arrow hit OR drop ghost is not displayed OR hasn't actually moved, move one more step in the same direction - if (this._keyDragFirstMove || - !this.dropGhostElement || - (this.getPreviousChip(this.dropGhostElement) === before && this.getNextChip(this.dropGhostElement) === after)) { - this._keyDragFirstMove = false; - this.arrowDrag(key); - } - } else { - //Dropping on '+ Condition button' => drop as last condition in the root group - let lastElement = this._dropZonesList[this._dropZonesList.length - 1].parentElement.previousElementSibling - if (lastElement.className.indexOf(QueryBuilderSelectors.FILTER_TREE_EXPRESSION_SECTION) !== -1) lastElement = lastElement.lastElementChild; - if (lastElement.className.indexOf(QueryBuilderSelectors.FILTER_TREE_SUBQUERY) !== -1) lastElement = lastElement.previousElementSibling; - if (lastElement === this.dropGhostChipNode) lastElement = lastElement.previousElementSibling; + const newDropTarget = this._possibleDropLocations[this._keyDragCurrentIndex]; + this._targetExpressionItem = newDropTarget[0] - const getParentExpression = (expression: ExpressionItem) => { - return expression.parent ? getParentExpression(expression.parent) : expression - }; - const rootGroup = getParentExpression(this._expressionsList[this._expressionsList.length - 1]); + this.renderDropGhostChip(newDropTarget[1]); - this.targetElement = lastElement as HTMLElement; - this.targetExpressionItem = rootGroup.children[rootGroup.children.length - 1]; - - this.renderDropGhostChip(lastElement as HTMLElement, true, true); + //Situations when drop ghost hasn't really moved, run one more time + if (this._keyDragCurrentIndex === this._keyDragInitialIndex || + (this._isKeyDragsFirstMove && this._keyDragCurrentIndex === this._keyDragInitialIndex - 1)) { + this._isKeyDragsFirstMove = false; + this.arrowDrag(key); } - } - - return; - } - /** Get previous chip area taking into account a possible hidden sub-tree or collapsed base chip*/ - private getPreviousChip(chipSubject: Element): Element { - let prevElement = chipSubject; - - do { - prevElement = prevElement?.previousElementSibling; + this._isKeyDragsFirstMove = false; } - while (prevElement && getComputedStyle(prevElement).display === 'none') - return prevElement; + return; } - /** Get next chip area taking into account a possible hidden sub-tree or collapsed base chip*/ - private getNextChip(chipSubject: Element): Element { - let nextElement = chipSubject; + /** Produces a flat ordered list of possible drop locations as Tuple <[targetExpression, dropUnder]>, while performing the keyboard drag&drop */ + private getPossibleDropLocations(group: ExpressionGroupItem, isRoot: boolean): Array<[ExpressionItem, boolean]> { + const result = new Array() as Array<[ExpressionItem, boolean]>; - do { - nextElement = nextElement?.nextElementSibling; - } - while (nextElement && getComputedStyle(nextElement).display === 'none') - - return nextElement; - } + //Add dropZone under AND/OR (as first child of group) + result.push([(group as ExpressionGroupItem).children[0], false]); - /** Get all expressions from the tree flatten out as a list, including the expression groups*/ - private getListedExpressions(group: ExpressionGroupItem): ExpressionItem[] { - const expressions: ExpressionItem[] = []; - - expressions.push(group); - group.children.forEach(child => { - if (child instanceof ExpressionGroupItem) { - expressions.push(...this.getListedExpressions(child)); + for (let i = 0; i < group.children.length; i++) { + if (group.children[i] instanceof ExpressionGroupItem) { + result.push(...this.getPossibleDropLocations(group.children[i] as ExpressionGroupItem, false)); } else { - expressions.push(child); + result.push([group.children[i], true]); } - }); - - return expressions; - } - - /** Gets all chip elements owned by this tree (discard child trees), AND/OR group roots and '+condition' button, flatten out as a list of HTML elements*/ - private getListedDropZones(): HTMLElement[] { - const expressionElementList = (this._queryBuilderTreeComponentElRef.nativeElement as HTMLElement).querySelectorAll(QueryBuilderSelectors.VIABLE_DROP_AREA); - const ownChipElements = []; - - const isNotFromThisTree = (qb, parent) => { - if (parent == qb) return false; - else if (parent?.style?.display === 'none' || parent.classList.contains(QueryBuilderSelectors.QUERY_BUILDER_TREE)) return true; - else if (parent.parentElement) return isNotFromThisTree(qb, parent.parentElement); - else return false; } - expressionElementList.forEach(element => { - if (!isNotFromThisTree(this._queryBuilderTreeComponentElRef.nativeElement, element) && getComputedStyle(element).display !== 'none') - ownChipElements.push(element); - }); + //Add dropZone under the whole group + if (!isRoot) { + result.push([group, true]); + } - return ownChipElements; + return result; } - /** Determine which chip to be focused after successful drop is completed*/ - private calculateDropLocationIndex(targetExpressionItem: ExpressionItem, sourceExpressionItem: ExpressionItem, dropUnder: boolean): number { - const expressions = this.getListedExpressions(this._queryBuilderTreeComponent.rootGroup); - - const ixt = expressions.indexOf(targetExpressionItem); - const ixs = expressions.indexOf(sourceExpressionItem); + /** Counts how many chips will be in the tree (from top to bottom) before the dropped one */ + private countChipsBeforeDropLocation(group: ExpressionGroupItem): [number, boolean] { + let count = 0, totalCount = 0, targetReached = false; - let dropLocationIndex = ixt - 1; - dropLocationIndex -= (expressions.filter((ex, ix) => !ex['expression'] && ix < ixt).length - 1); //deduct group roots + for (let i = 0; i < group.children.length; i++) { + const child = group.children[i]; - if (!dropUnder && ixs < ixt) dropLocationIndex -= 1; + if (targetReached) { + break; + } - if (dropUnder && ixs > ixt) dropLocationIndex += 1; + if (child instanceof ExpressionGroupItem) { + if (child === this._targetExpressionItem) { + if (this._dropUnder) { + [count] = this.countChipsBeforeDropLocation(child as ExpressionGroupItem); + totalCount += count; + } + targetReached = true; + } else { + [count, targetReached] = this.countChipsBeforeDropLocation(child as ExpressionGroupItem); + totalCount += count; + } + } else { + if (child !== this._sourceExpressionItem && //not the hidden source chip + child !== this.dropGhostExpression && //not the drop ghost + !((child as ExpressionOperandItem).inEditMode && this._queryBuilderTreeComponent.operandCanBeCommitted() !== true) //not a chip in edit mode that will be discarded + ) { + totalCount++; + } - //if dropping under empty edited condition (which will be discarded) - if (dropUnder && targetExpressionItem['expression'] && - !targetExpressionItem['expression'].fieldName && - !targetExpressionItem['expression'].condition) dropLocationIndex -= 1; + if (child === this._targetExpressionItem) { + targetReached = true; + if (!this._dropUnder && + !((child as ExpressionOperandItem).inEditMode && this._queryBuilderTreeComponent.operandCanBeCommitted() !== true)) { + totalCount--; + } + } + } + } - //if dropped on the +Condition button - if (dropUnder && !targetExpressionItem['expression']) dropLocationIndex = expressions.filter(ex => ex['expression']).length - 1; + totalCount === -1 && totalCount++; - return dropLocationIndex; + return [totalCount, targetReached]; } - /** Sets the z-index of the drag ghost with a little delay, since we don't have access to ghostCreated() but we know it's executed right after moveStart()*/ - private setDragGhostZIndex(): void { + /** Sets the z-index of the drag ghost with a little delay, since we don't have access to ghostCreated() but we know it's executed right after moveStart() */ + private setDragGhostZIndex() { if (this._timeoutId) { clearTimeout(this._timeoutId); } this._timeoutId = setTimeout(() => { - if (this.dragGhostElement?.style) this.dragGhostElement.style.zIndex = `${Z_INDEX_TO_SET}`; + if (this.getDragGhostElement?.style) { + this.getDragGhostElement.style.zIndex = `${Z_INDEX_TO_SET}`; + } }, DEFAULT_SET_Z_INDEX_DELAY); } } \ No newline at end of file diff --git a/projects/igniteui-angular/src/lib/query-builder/query-builder-functions.spec.ts b/projects/igniteui-angular/src/lib/query-builder/query-builder-functions.spec.ts index 3762599957c..d824c7547ab 100644 --- a/projects/igniteui-angular/src/lib/query-builder/query-builder-functions.spec.ts +++ b/projects/igniteui-angular/src/lib/query-builder/query-builder-functions.spec.ts @@ -865,7 +865,7 @@ export class QueryBuilderFunctions { } public static getChipContent(chip: Element): string { - if (chip.checkVisibility()) { + if (chip && chip.checkVisibility()) { let text: string = ''; Array.from(chip.querySelectorAll('span')).forEach(element => { @@ -883,7 +883,8 @@ export class QueryBuilderFunctions { public static getDropGhost(fixture: ComponentFixture): Element { var expressionsContainer = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fixture); - return expressionsContainer.querySelector(`div.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST}`); + return expressionsContainer.querySelector(`div.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST}`) ?? + expressionsContainer.querySelector(`div.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_KEYBOARD_GHOST}`); } public static getDropGhostBounds(fixture: ComponentFixture): DOMRect { @@ -910,4 +911,27 @@ export class QueryBuilderFunctions { dragDirective.onPointerUp({ pointerId: 1, pageX: X, pageY: Y }); } } + + public static getDropGhostAndItsSiblings(fixture: ComponentFixture): [Element, string, string, string[]] { + const dropGhost = this.getDropGhost(fixture); + const newChipContents = QueryBuilderFunctions.GetChipsContentAsArray(fixture); + let prevElement: string, nextElement: string; + + if (dropGhost) { + if (dropGhost.previousElementSibling?.className && + dropGhost.previousElementSibling?.className?.indexOf(QueryBuilderSelectors.FILTER_TREE_SUBQUERY) !== -1) { + prevElement = QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling.previousElementSibling); + } else if (dropGhost.previousElementSibling?.previousElementSibling) { + prevElement = QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling); + } + + nextElement = QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling?.nextElementSibling); + nextElement ??= QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling?.nextElementSibling?.nextElementSibling?.nextElementSibling); + } + + prevElement ??= null; + nextElement ??= null; + + return [dropGhost, prevElement, nextElement, newChipContents]; + } } diff --git a/projects/igniteui-angular/src/lib/query-builder/query-builder-tree.component.html b/projects/igniteui-angular/src/lib/query-builder/query-builder-tree.component.html index 89f198e2180..2197f888cd8 100644 --- a/projects/igniteui-angular/src/lib/query-builder/query-builder-tree.component.html +++ b/projects/igniteui-angular/src/lib/query-builder/query-builder-tree.component.html @@ -125,6 +125,13 @@ @if (!expressionItem.inEditMode) { + @if(dragService.dropGhostExpression && expressionItem === dragService.dropGhostExpression && dragService.isKeyboardDrag === false){ +
+ + {{this.resourceStrings.igx_query_builder_drop_ghost_text}} + +
+ } @else {
} + }
@if (expressionItem.inEditMode) {
; + public expressionsChips: QueryList; @ViewChild('editingInputsContainer', { read: ElementRef }) protected set editingInputsContainer(value: ElementRef) { @@ -459,7 +472,6 @@ export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { private _prevFocusedContainer: ElementRef; private _expandedExpressions: IFilteringExpression[] = []; private _fields: FieldType[]; - private _expressionTree: IExpressionTree; private _locale; private _entityNewValue: EntityType; private _resourceStrings = getCurrentResourceStrings(QueryBuilderResourceStringsEN); @@ -475,7 +487,7 @@ export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { /** * Returns if the fields combo at the root level is disabled. */ - public get disableReturnFieldsChange(): boolean { + public get disableReturnFieldsChange(): boolean { return !this.selectedEntity || this.queryBuilder.disableReturnFieldsChange; } @@ -1037,7 +1049,6 @@ export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { this._lastFocusedChipIndex = index; this.focusEditedExpressionChip(); } - /** * @hidden @internal */ @@ -1139,8 +1150,8 @@ export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { expressionItem.expression.condition.name : null; this.searchValue.value = expressionItem.expression.searchVal instanceof Set ? - Array.from(expressionItem.expression.searchVal) : - expressionItem.expression.searchVal; + Array.from(expressionItem.expression.searchVal) : + expressionItem.expression.searchVal; expressionItem.inEditMode = true; this._editedExpression = expressionItem; @@ -1175,7 +1186,7 @@ export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { input?.focus(); } - (this.editingInputs?.nativeElement.parentElement as HTMLElement)?.scrollIntoView({block: "nearest", inline: "nearest"}); + (this.editingInputs?.nativeElement.parentElement as HTMLElement)?.scrollIntoView({ block: "nearest", inline: "nearest" }); } /** @@ -1315,7 +1326,7 @@ export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { * @hidden @internal */ public invokeClick(eventArgs: KeyboardEvent) { - if (!this.dragService.dropGhostChipNode && this.platform.isActivationKey(eventArgs)) { + if (!this.dragService.dropGhostExpression && this.platform.isActivationKey(eventArgs)) { eventArgs.preventDefault(); (eventArgs.currentTarget as HTMLElement).click(); } @@ -1547,7 +1558,7 @@ export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { return groupItem; } - for (let i = 0 ; i < expressionTree.filteringOperands.length; i++) { + for (let i = 0; i < expressionTree.filteringOperands.length; i++) { const expr = expressionTree.filteringOperands[i]; if (isTree(expr)) { diff --git a/projects/igniteui-angular/src/lib/query-builder/query-builder.common.ts b/projects/igniteui-angular/src/lib/query-builder/query-builder.common.ts index 213492cc975..c4278af4802 100644 --- a/projects/igniteui-angular/src/lib/query-builder/query-builder.common.ts +++ b/projects/igniteui-angular/src/lib/query-builder/query-builder.common.ts @@ -69,6 +69,7 @@ export const QueryBuilderSelectors = { FILTER_TREE_EXPRESSION_CONTEXT_MENU: IGX_FILTER_TREE + '__expression-context-menu', FILTER_TREE_EXPRESSION_ITEM: IGX_FILTER_TREE + '__expression-item', FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST: IGX_FILTER_TREE + '__expression-item-drop-ghost', + FILTER_TREE_EXPRESSION_ITEM_KEYBOARD_GHOST: IGX_FILTER_TREE + '__expression-item-keyboard-ghost', FILTER_TREE_EXPRESSION_ITEM_GHOST: IGX_FILTER_TREE + '__expression-item-ghost', FILTER_TREE_EXPRESSION_SECTION: IGX_FILTER_TREE + '__expression-section', @@ -80,10 +81,4 @@ export const QueryBuilderSelectors = { QUERY_BUILDER_BODY: IGX_QUERY_BUILDER + '__main', QUERY_BUILDER_HEADER: IGX_QUERY_BUILDER + '__header', QUERY_BUILDER_TREE: IGX_QUERY_BUILDER + '-tree', - - VIABLE_DROP_AREA: - `.${IGX_FILTER_TREE}__expression-item[igxDrop]:not(.${IGX_FILTER_TREE + '__expression-item-drop-ghost'}),` + /*Condition chip*/ - `.${IGX_FILTER_TREE}__subquery:has([igxDrop]),` + /*Chip in edit*/ - `.${IGX_FILTER_TREE}__buttons > .igx-button[igxDrop]:first-of-type,` + /*Add Condition Button*/ - `.${IGX_FILTER_TREE}__expression-context-menu[igxDrop]` /*AND/OR group root*/ } diff --git a/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts b/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts index 3590621ab81..8ebd9776228 100644 --- a/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts +++ b/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts @@ -2430,7 +2430,7 @@ describe('IgxQueryBuilder', () => { expect(dropGhost).toBeDefined(); expect(dropGhost.innerText).toBe('My Drop here to insert'); })); - }); + }); describe('Drag and drop', () => { const ROW_HEIGHT = 40; @@ -2466,7 +2466,7 @@ describe('IgxQueryBuilder', () => { const draggedChipCenter = QueryBuilderFunctions.getElementCenter(draggedChip.chipArea.nativeElement); const dragDir = draggedChip.dragDirective; - let X = 100, Y = 95; + let X = 100, Y = 75; //pickup chip dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); @@ -2485,52 +2485,50 @@ describe('IgxQueryBuilder', () => { //Drag ghost up and down four times and check if drop ghost is rendered in the expected positions while (pass <= 4) { i += inc; - Y += 5 * inc; + Y += inc; QueryBuilderFunctions.dragMove(dragDir, X, Y); - tick(10); + tick(); fix.detectChanges(); - const dropGhost = QueryBuilderFunctions.getDropGhost(fix); - const prevElement = dropGhost && dropGhost.previousElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling) : null; - const nextElement = dropGhost && dropGhost.nextElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling) : null; + const [dropGhost, prevElement, nextElement] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); - if (i < 8 && !ghostPositionVisits[0]) { - tick(50); + if (i < 40 && !ghostPositionVisits[0]) { + if (i <= 42) tick(50); if (!dropGhost) ghostPositionVisits[0] = true; } - if (i > 6 && i < 23 && !ghostPositionVisits[1]) { - if (dropGhost && !prevElement && nextElement == "OrderName Equals foo") ghostPositionVisits[1] = true; + if (i > 35 && i < 122 && !ghostPositionVisits[1]) { + if (dropGhost && !prevElement && nextElement == 'OrderName Equals foo') ghostPositionVisits[1] = true; } - if (i > 20 && i < 35 && !ghostPositionVisits[2]) { - if (dropGhost && prevElement == "OrderName Equals foo" && !nextElement) ghostPositionVisits[2] = true; + if (i > 120 && i < 165 && !ghostPositionVisits[2]) { + if (dropGhost && prevElement == 'OrderName Equals foo' && nextElement === 'or OrderName Ends With a OrderDate Today') ghostPositionVisits[2] = true; } - if (i > 31 && i < 40 && !ghostPositionVisits[3]) { - if (dropGhost && !prevElement && nextElement == "OrderName Ends With a") ghostPositionVisits[3] = true; + if (i > 166 && i < 201 && !ghostPositionVisits[3]) { + if (dropGhost && !prevElement && nextElement == 'OrderName Ends With a') ghostPositionVisits[3] = true; } - if (i > 36 && i < 47 && !ghostPositionVisits[4]) { - if (dropGhost && prevElement == "OrderName Ends With a" && !nextElement) ghostPositionVisits[4] = true; + if (i > 202 && i < 241 && !ghostPositionVisits[4]) { + if (dropGhost && prevElement == 'OrderName Ends With a' && nextElement === 'OrderDate Today') ghostPositionVisits[4] = true; } - if (i > 44 && i < 57 && !ghostPositionVisits[5]) { - if (dropGhost && prevElement == "OrderDate Today" && !nextElement) ghostPositionVisits[5] = true; + if (i > 240 && i < 273 && !ghostPositionVisits[5]) { + if (dropGhost && prevElement == 'OrderDate Today' && !nextElement) ghostPositionVisits[5] = true; } - if (i > 54 && i < 64 && !ghostPositionVisits[6]) { - if (pass > 2 || (dropGhost && prevElement == "or OrderName Ends With a OrderDate Today" && !nextElement)) ghostPositionVisits[6] = true; + if (i > 256 && i < 316 && !ghostPositionVisits[6]) { + if (pass > 2 || (dropGhost && prevElement == 'or OrderName Ends With a OrderDate Today' && !nextElement)) ghostPositionVisits[6] = true; } - if (i > 62 && !ghostPositionVisits[7]) { - tick(50); + if (i > 320 && !ghostPositionVisits[7]) { + if (i >= 340) tick(50); if (!dropGhost) ghostPositionVisits[7] = true; } //When dragged to the end, check results and reverse direction for next pass - if (i === 65 || i === 0) { + if (i === 350 || i === 0) { expect(ghostPositionVisits).not.toContain(false, `Ghost was not rendered on position(s) ${ghostPositionVisits.reduce((arr, e, ix) => ((e == false) && arr.push(ix), arr), []).toString()} on pass:${pass}`); @@ -2659,8 +2657,12 @@ describe('IgxQueryBuilder', () => { dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); fix.detectChanges(); + //trigger ghost + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X + 50, draggedChipCenter.Y - 50); + fix.detectChanges(); + //drag - QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X, draggedChipCenter.Y - 2 * ROW_HEIGHT, true); + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X + 50, draggedChipCenter.Y - 50, true); fix.detectChanges(); chipComponents = QueryBuilderFunctions.getVisibleChips(fix); @@ -2744,6 +2746,7 @@ describe('IgxQueryBuilder', () => { //move over +Condition QueryBuilderFunctions.dragMove(dragDir, addConditionButtonCenter.X, addConditionButtonCenter.Y); + fix.detectChanges(); const dropGhost = QueryBuilderFunctions.getDropGhost(fix) as HTMLElement; chipComponents = QueryBuilderFunctions.getVisibleChips(fix); @@ -2971,33 +2974,30 @@ describe('IgxQueryBuilder', () => { tick(20); fix.detectChanges(); - const dropGhost = QueryBuilderFunctions.getDropGhost(fix); - const prevElement = dropGhost && dropGhost.previousElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling) : null; - const nextElement = dropGhost && dropGhost.nextElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling) : null; - const newChipContents = QueryBuilderFunctions.GetChipsContentAsArray(fix); + const [dropGhost, prevElement, nextElement, newChipContents] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); switch (true) { case i === 0: expect(dropGhost).toBeDefined(); expect(prevElement).toBeNull(); - expect(nextElement).toEqual("OrderName Ends With a"); + expect(nextElement).toEqual('OrderName Ends With a'); expect(newChipContents[4]).toBe(dropGhostContent); break; case i === 1: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("OrderName Ends With a"); - expect(nextElement).toBeUndefined(); + expect(prevElement).toEqual('OrderName Ends With a'); + expect(nextElement).toEqual('OrderDate Today'); expect(newChipContents[5]).toBe(dropGhostContent); break; case i === 2: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("OrderDate Today"); - expect(nextElement).toBeUndefined(); + expect(prevElement).toEqual('OrderDate Today'); + expect(nextElement).toBeNull(); expect(newChipContents[6]).toBe(dropGhostContent); break; case i >= 3: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("or OrderName Ends With a OrderDate Today"); + expect(prevElement).toEqual('or OrderName Ends With a OrderDate Today'); expect(nextElement).toBeNull(); expect(newChipContents[6]).toBe(dropGhostContent); break; @@ -3011,40 +3011,37 @@ describe('IgxQueryBuilder', () => { tick(20); fix.detectChanges(); - const dropGhost = QueryBuilderFunctions.getDropGhost(fix); - const prevElement = dropGhost && dropGhost.previousElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling) : null; - const nextElement = dropGhost && dropGhost.nextElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling) : null; - const newChipContents = QueryBuilderFunctions.GetChipsContentAsArray(fix); + const [dropGhost, prevElement, nextElement, newChipContents] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); switch (true) { case i === 0: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("OrderDate Today"); - expect(nextElement).toBeUndefined(); + expect(prevElement).toEqual('OrderDate Today'); + expect(nextElement).toBeNull(); expect(newChipContents[6]).toBe(dropGhostContent); break; case i === 1: expect(dropGhost).toBeDefined(); - expect(prevElement).toBeUndefined(); - expect(nextElement).toEqual("OrderDate Today"); + expect(prevElement).toEqual('OrderName Ends With a'); + expect(nextElement).toEqual('OrderDate Today'); expect(newChipContents[5]).toBe(dropGhostContent); break; case i === 2: expect(dropGhost).toBeDefined(); expect(prevElement).toBeNull(); - expect(nextElement).toEqual("OrderName Ends With a"); + expect(nextElement).toEqual('OrderName Ends With a'); expect(newChipContents[4]).toBe(dropGhostContent); break; case i === 3: expect(dropGhost).toBeDefined(); - expect(prevElement).toBeUndefined(); - expect(nextElement).toEqual("or OrderName Ends With a OrderDate Today"); - expect(newChipContents[4]).toBe(dropGhostContent); + expect(prevElement).toEqual('OrderName Equals foo'); + expect(nextElement).toEqual('or OrderName Ends With a OrderDate Today'); + expect(newChipContents[1]).toBe(dropGhostContent); break; case i >= 4: expect(dropGhost).toBeDefined(); expect(prevElement).toBeNull(); - expect(nextElement).toEqual("OrderName Equals foo"); + expect(nextElement).toEqual('OrderName Equals foo'); expect(newChipContents[0]).toBe(dropGhostContent); break; } @@ -3057,45 +3054,174 @@ describe('IgxQueryBuilder', () => { tick(20); fix.detectChanges(); - const dropGhost = QueryBuilderFunctions.getDropGhost(fix); - const prevElement = dropGhost && dropGhost.previousElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling) : null; - const nextElement = dropGhost && dropGhost.nextElementSibling ? QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling) : null; - const newChipContents = QueryBuilderFunctions.GetChipsContentAsArray(fix); + const [dropGhost, prevElement, nextElement, newChipContents] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); switch (true) { case i === 0: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("OrderName Equals foo"); - expect(nextElement).toBeUndefined(); + expect(prevElement).toEqual('OrderName Equals foo'); + expect(nextElement).toEqual('or OrderName Ends With a OrderDate Today'); expect(newChipContents[1]).toBe(dropGhostContent); break; case i === 1: expect(dropGhost).toBeDefined(); expect(prevElement).toBeNull(); - expect(nextElement).toEqual("OrderName Ends With a"); + expect(nextElement).toEqual('OrderName Ends With a'); expect(newChipContents[4]).toBe(dropGhostContent); break; case i === 2: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("OrderName Ends With a"); - expect(nextElement).toBeUndefined(); + expect(prevElement).toEqual('OrderName Ends With a'); + expect(nextElement).toEqual('OrderDate Today'); expect(newChipContents[5]).toBe(dropGhostContent); break; case i === 3: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("OrderDate Today"); - expect(nextElement).toBeUndefined(); + expect(prevElement).toEqual('OrderDate Today'); + expect(nextElement).toBeNull(); expect(newChipContents[6]).toBe(dropGhostContent); break; case i >= 4: expect(dropGhost).toBeDefined(); - expect(prevElement).toEqual("or OrderName Ends With a OrderDate Today"); + expect(prevElement).toEqual('or OrderName Ends With a OrderDate Today'); expect(nextElement).toBeNull(); expect(newChipContents[6]).toBe(dropGhostContent); break; } } })); + + it('Should commit drop upon hitting \'Enter\' when keyboard dragged.', fakeAsync(() => { + const draggedIndicator = fix.debugElement.queryAll(By.css('.igx-drag-indicator'))[4]; + const tree = fix.debugElement.query(By.css('.igx-filter-tree')); + + draggedIndicator.triggerEventHandler('focus', {}); + draggedIndicator.nativeElement.focus(); + + spyOn(tree.nativeElement, 'dispatchEvent').and.callThrough(); + const dropGhostContent = QueryBuilderFunctions.GetChipsContentAsArray(fix)[1]; + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + tick(20); + fix.detectChanges(); + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + tick(20); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderName", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": "foo" + }, + { + "fieldName": "OrderId", + "condition": { + "name": "inQuery", + "isUnary": false, + "isNestedQuery": true, + "iconName": "in" + }, + "conditionName": "inQuery", + "searchTree": { + "filteringOperands": [ + { + "fieldName": "Id", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": 123 + }, + { + "fieldName": "ProductName", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": "abc" + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "OrderId" + ] + } + }, + { + "filteringOperands": [ + { + "fieldName": "OrderDate", + "condition": { + "name": "today", + "isUnary": true, + "iconName": "filter_today" + }, + "conditionName": "today" + }, + { + "fieldName": "OrderName", + "condition": { + "name": "endsWith", + "isUnary": false, + "iconName": "filter_ends_with" + }, + "conditionName": "endsWith", + "searchVal": "a" + } + ], + "operator": 1, + "entity": "Orders", + "returnFields": [ + "*" + ] + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "*" + ] +}`); + })); + + it('Should cancel drop upon hitting \'Escape\' when keyboard dragged.', fakeAsync(() => { + const draggedIndicator = fix.debugElement.queryAll(By.css('.igx-drag-indicator'))[4]; + const tree = fix.debugElement.query(By.css('.igx-filter-tree')); + + draggedIndicator.triggerEventHandler('focus', {}); + draggedIndicator.nativeElement.focus(); + + spyOn(tree.nativeElement, 'dispatchEvent').and.callThrough(); + const dropGhostContent = QueryBuilderFunctions.GetChipsContentAsArray(fix)[1]; + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + tick(20); + fix.detectChanges(); + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + tick(20); + fix.detectChanges(); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(QueryBuilderFunctions.getChipContent(chipComponents[2].nativeElement)).toBe("OrderName Ends With a"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[3].nativeElement)).toBe("OrderDate Today"); + })); + }); });