From 56eb1cff42b19a61daae9660a156456a9b1a80b9 Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Thu, 5 Dec 2024 16:33:10 -0300 Subject: [PATCH 01/11] fix: ensure the component fully renders before proceeding to the next step in ionTour --- .../lib/tour/mocks/tour-basic-demo.component.ts | 8 ++++++++ projects/ion/src/lib/tour/tour.service.ts | 17 ++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts b/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts index 0142c85e6..132a49609 100644 --- a/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts +++ b/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts @@ -85,8 +85,10 @@ export const STEP3_MOCK: IonTourStepProps = { [ionNextStepId]="step2.ionNextStepId" [ionStepTitle]="step2.ionStepTitle" [ionStepBody]="saveStep" + (ionOnNextStep)="markOptionStepAsVisible()" > { + const nextStep = this._tours[this.activeTourId].get( + currentStep.ionNextStepId + ); + + this.navigateToStep(nextStep); + }); } private getFirstStep( From 63af2245fe148bb9ae745f6538ba54da0ddfe5d1 Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Fri, 6 Dec 2024 10:07:14 -0300 Subject: [PATCH 02/11] fix: removing popover blinking at leftTop position of page --- projects/ion/src/lib/core/types/tour.ts | 2 +- .../tour-backdrop/tour-backdrop.component.ts | 4 +-- .../ion/src/lib/tour/tour-step.directive.ts | 20 +++++++++--- .../ion/src/lib/tour/tour.service.spec.ts | 11 ++++--- projects/ion/src/lib/tour/tour.service.ts | 31 +++++++++++-------- 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/projects/ion/src/lib/core/types/tour.ts b/projects/ion/src/lib/core/types/tour.ts index 0ab773f4d..a5825e59e 100644 --- a/projects/ion/src/lib/core/types/tour.ts +++ b/projects/ion/src/lib/core/types/tour.ts @@ -19,7 +19,7 @@ export interface IonTourStepProps { ionOnPrevStep?: EventEmitter; ionOnNextStep?: EventEmitter; ionOnFinishTour?: EventEmitter; - target?: DOMRect; + getTarget?: () => DOMRect; } export interface IonStartTourProps { diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts index b1a2452da..22cbb842e 100644 --- a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts @@ -19,8 +19,8 @@ export class IonTourBackdropComponent implements OnInit { return ''; } - const { target, ionStepBackdropPadding: padding } = this.currentStep; - const { top, left, bottom, right } = target; + const { getTarget, ionStepBackdropPadding: padding } = this.currentStep; + const { top, left, bottom, right } = getTarget(); return this.sanitizer.bypassSecurityTrustStyle(`polygon( 0 0, diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index 66192870d..2c50cf0b1 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -72,16 +72,25 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.tourService.activeTour$ .pipe(takeUntil(this.destroy$)) - .subscribe((isActive) => { - this.isTourActive = isActive === this.ionTourId; + .subscribe((activeTourId) => { + this.isTourActive = activeTourId === this.ionTourId; this.checkPopoverVisibility(); }); this.tourService.currentStep$ .pipe(takeUntil(this.destroy$)) .subscribe((step) => { - this.isStepSelected = step && step.ionStepId === this.ionStepId; - this.checkPopoverVisibility(); + if (!this.isStepSelected && step && step.ionStepId === this.ionStepId) { + this.isStepSelected = true; + this.checkPopoverVisibility(); + } else if ( + this.isStepSelected && + step && + step.ionStepId !== this.ionStepId + ) { + this.isStepSelected = false; + this.checkPopoverVisibility(); + } }); } @@ -178,6 +187,7 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { } this.cdr.detectChanges(); + this.repositionPopover(); } private listenToPopoverEvents(): void { @@ -220,7 +230,7 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { ionOnPrevStep: this.ionOnPrevStep, ionOnNextStep: this.ionOnNextStep, ionOnFinishTour: this.ionOnFinishTour, - target: this.elementRef.nativeElement.getBoundingClientRect(), + getTarget: () => this.elementRef.nativeElement.getBoundingClientRect(), }; } } diff --git a/projects/ion/src/lib/tour/tour.service.spec.ts b/projects/ion/src/lib/tour/tour.service.spec.ts index bd9d6b483..09feab374 100644 --- a/projects/ion/src/lib/tour/tour.service.spec.ts +++ b/projects/ion/src/lib/tour/tour.service.spec.ts @@ -55,7 +55,7 @@ const stepsMock: IonTourStepProps[] = [ { ionTourId: 'tour1', ionStepId: 'step1', - target: TARGET_MOCK, + getTarget: () => TARGET_MOCK, ionStepTitle: 'Step 1', ionNextStepId: 'step2', ionOnPrevStep: new EventEmitter(), @@ -65,7 +65,7 @@ const stepsMock: IonTourStepProps[] = [ { ionTourId: 'tour1', ionStepId: 'step2', - target: TARGET_MOCK, + getTarget: () => TARGET_MOCK, ionStepTitle: 'Step 2', ionPrevStepId: 'step1', ionOnPrevStep: new EventEmitter(), @@ -237,6 +237,7 @@ describe('IonTourService', () => { const spy = jest.spyOn(step1.ionOnNextStep, 'emit'); service.nextStep(); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(step2); expect(spy).toHaveBeenCalledTimes(1); @@ -251,6 +252,7 @@ describe('IonTourService', () => { jest.runAllTimers(); service.nextStep(); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(step2); @@ -271,9 +273,10 @@ describe('IonTourService', () => { const spyNext = jest.spyOn(step.ionOnNextStep, 'emit'); const spyFinish = jest.spyOn(step.ionOnFinishTour, 'emit'); service.nextStep(); + jest.runAllTimers(); - expect(service.currentStep.value).toBeNull(); - expect(service.activeTour.value).toBeNull(); + expect(service.currentStep.value).toBeUndefined(); + expect(service.activeTour.value).toBeUndefined(); expect(spyNext).toHaveBeenCalledTimes(1); expect(spyFinish).toHaveBeenCalledTimes(1); }); diff --git a/projects/ion/src/lib/tour/tour.service.ts b/projects/ion/src/lib/tour/tour.service.ts index 1a7bd3df3..33856fb59 100644 --- a/projects/ion/src/lib/tour/tour.service.ts +++ b/projects/ion/src/lib/tour/tour.service.ts @@ -59,7 +59,7 @@ export class IonTourService { if ( current && current.ionStepId === step.ionStepId && - !isEqual(step.target.toJSON(), current.target.toJSON()) + !isEqual(step.getTarget().toJSON(), current.getTarget().toJSON()) ) { this.navigateToStep(step); } @@ -105,28 +105,33 @@ export class IonTourService { public prevStep(): void { const currentStep = this.currentStep.getValue(); + if (!currentStep.ionPrevStepId) { + this.finish(); + return; + } + currentStep.ionOnPrevStep.emit(); - const prevStep = this._tours[this.activeTourId].get( - currentStep.ionPrevStepId - ); + setTimeout(() => { + const prevStep = this._tours[this.activeTourId].get( + currentStep.ionPrevStepId + ); - if (prevStep) { - this.navigateToStep(prevStep); - } else { - this.finish(); - } + if (prevStep) { + this.navigateToStep(prevStep); + } + }); } public nextStep(): void { const currentStep = this.currentStep.getValue(); - currentStep.ionOnNextStep.emit(); - if (!currentStep.ionNextStepId) { this.finish(); return; } + currentStep.ionOnNextStep.emit(); + setTimeout(() => { const nextStep = this._tours[this.activeTourId].get( currentStep.ionNextStepId @@ -162,10 +167,10 @@ export class IonTourService { this.appRef.attachView(this.backdropRef.hostView); - const popoverElement = this.backdropRef.location + const backdropElement = this.backdropRef.location .nativeElement as HTMLElement; - this.document.body.appendChild(popoverElement); + this.document.body.appendChild(backdropElement); this.backdropRef.changeDetectorRef.detectChanges(); this.updateBackdropProps(); } From eec0076c992ee16f9faa8239a8294ee3a27447d6 Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Fri, 6 Dec 2024 11:51:35 -0300 Subject: [PATCH 03/11] fix: validating host position changes --- .../ion/src/lib/tour/tour-step.directive.ts | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index 2c50cf0b1..66b9788d4 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -11,6 +11,7 @@ import { Inject, Injector, Input, + NgZone, OnChanges, OnDestroy, OnInit, @@ -55,6 +56,9 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { private isTourActive = false; private destroy$ = new Subject(); + private observer: NodeJS.Timer; + private hostPosition: DOMRect; + constructor( @Inject(DOCUMENT) private document: SafeAny, private componentFactoryResolver: ComponentFactoryResolver, @@ -64,7 +68,8 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { private injector: Injector, private cdr: ChangeDetectorRef, private tourService: IonTourService, - private positionService: IonPositionService + private positionService: IonPositionService, + private ngZone: NgZone ) {} public ngOnInit(): void { @@ -80,16 +85,12 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.tourService.currentStep$ .pipe(takeUntil(this.destroy$)) .subscribe((step) => { - if (!this.isStepSelected && step && step.ionStepId === this.ionStepId) { - this.isStepSelected = true; - this.checkPopoverVisibility(); - } else if ( - this.isStepSelected && - step && - step.ionStepId !== this.ionStepId - ) { - this.isStepSelected = false; - this.checkPopoverVisibility(); + if (step) { + const isSameStep = step.ionStepId === this.ionStepId; + if (this.isStepSelected !== isSameStep) { + this.isStepSelected = isSameStep; + this.checkPopoverVisibility(); + } } }); } @@ -106,6 +107,21 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.tourService.removeStep(this.ionStepId); this.destroy$.next(); this.destroy$.complete(); + if (this.observer) { + clearInterval(this.observer); + } + } + + private observeHostPosition(): void { + this.ngZone.runOutsideAngular(() => { + this.observer = setInterval(() => { + this.ngZone.run(() => { + if (this.hostPositionChanged()) { + this.repositionPopover(); + } + }); + }, 0); + }); } @HostListener('window:resize', ['$event']) @@ -114,9 +130,8 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { const contentRect = this.popoverRef.instance.popover.nativeElement.getBoundingClientRect(); - this.positionService.setHostPosition( - this.elementRef.nativeElement.getBoundingClientRect() - ); + this.hostPosition = this.elementRef.nativeElement.getBoundingClientRect(); + this.positionService.setHostPosition(this.hostPosition); this.positionService.setChoosedPosition(this.ionStepPosition); this.positionService.setElementPadding(this.ionStepMarginToContent); this.positionService.setcomponentCoordinates(contentRect); @@ -140,7 +155,10 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.destroyPopoverElement(); if (this.isTourActive && this.isStepSelected) { - setTimeout(() => this.createPopoverElement()); + setTimeout(() => { + this.createPopoverElement(); + this.observeHostPosition(); + }); } } @@ -204,6 +222,13 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { }); } + private hostPositionChanged(): boolean { + return ( + this.elementRef.nativeElement.getBoundingClientRect().toJSON() !== + this.hostPosition.toJSON() + ); + } + private destroyPopoverElement(): void { if (this.popoverRef) { this.appRef.detachView(this.popoverRef.hostView); From 243db355617a5049d1cf57c867de24dcd02dc21b Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Mon, 9 Dec 2024 15:50:34 -0300 Subject: [PATCH 04/11] test: testing tour resizing --- .../mocks/resizing-host-demo.component.ts | 80 +++++++++++++++++++ .../tour-backdrop.component.spec.ts | 4 +- .../src/lib/tour/tour-step.directive.spec.ts | 73 +++++++++++++++-- .../ion/src/lib/tour/tour-step.directive.ts | 18 +++-- .../ion/src/lib/tour/tour.service.spec.ts | 11 ++- projects/ion/src/lib/tour/tour.service.ts | 10 +-- stories/TourResizing.stories.ts | 33 ++++++++ 7 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts create mode 100644 stories/TourResizing.stories.ts diff --git a/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts b/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts new file mode 100644 index 000000000..4250d2052 --- /dev/null +++ b/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts @@ -0,0 +1,80 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { IonTourService } from '../tour.service'; + +@Component({ + selector: 'tour-resizing-host', + styles: [ + ` + main { + height: 800px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + ion-button { + position: absolute; + } + } + `, + ], + template: ` +
+ +
+ + +

+ The host is changing its size and position, but the tour should still + work +

+
+ `, +}) +export class TourResizingHostComponent implements OnInit, OnDestroy { + public top = 100; + public left = 50; + public buttonWidth = 100; + + private interval: ReturnType; + + constructor(private readonly ionTourService: IonTourService) {} + + public ngOnInit(): void { + this.ionTourService.start(); + this.animateButtonSize(); + } + + private animateButtonSize(): void { + this.interval = setInterval(() => { + this.top = this.getRandomNumber(0, 700); + this.left = this.getRandomNumber(0, 500); + this.buttonWidth = this.getRandomNumber(50, 300); + }, 700); + } + + private getRandomNumber(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + public ngOnDestroy(): void { + if (this.interval) { + clearInterval(this.interval); + } + } +} diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts index 5cf0bdb85..1a53da1a2 100644 --- a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts @@ -15,7 +15,7 @@ const sut = async ( }; const STEP_MOCK = { - target: { + getTarget: () => ({ x: 300, y: 300, width: 100, @@ -24,7 +24,7 @@ const STEP_MOCK = { right: 400, left: 300, top: 300, - } as DOMRect, + }), } as IonTourStepProps; describe('IonTourBackdropComponent', () => { diff --git a/projects/ion/src/lib/tour/tour-step.directive.spec.ts b/projects/ion/src/lib/tour/tour-step.directive.spec.ts index 3f53bf16f..007975b03 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.spec.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.spec.ts @@ -1,19 +1,24 @@ +import { ChangeDetectorRef, ElementRef, ViewContainerRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { fireEvent, render, RenderResult, screen, } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; import { cloneDeep } from 'lodash'; import { EMPTY, of } from 'rxjs'; import { IonButtonModule } from '../button/button.module'; import { IonTourStepProps } from '../core/types'; +import { IonPopoverModule } from '../popover/popover.module'; +import { TourResizingHostComponent } from './mocks/resizing-host-demo.component'; import { TourStepDemoComponent } from './mocks/tour-step-props.component'; import { IonTourModule } from './tour.module'; import { IonTourService } from './tour.service'; -import { IonPopoverModule } from '../popover/popover.module'; -import userEvent from '@testing-library/user-event'; +import { IonTourStepDirective } from './tour-step.directive'; +import { IonPositionService } from '../position/position.service'; const DEFAULT_PROPS: Partial = { ionTourId: 'demo-tour', @@ -23,7 +28,7 @@ const DEFAULT_PROPS: Partial = { ionNextStepBtn: { label: 'Test Next' }, }; -const tourServiceMock: Partial = { +const tourServiceMock = { saveStep: jest.fn(), removeStep: jest.fn(), start: jest.fn(), @@ -34,6 +39,26 @@ const tourServiceMock: Partial = { currentStep$: EMPTY, }; +const generateRandomDOMRect = (): DOMRect => { + const data = { + x: Math.random() * 100, + y: Math.random() * 100, + width: Math.random() * 200 + 50, + height: Math.random() * 200 + 50, + bottom: Math.random() * 100 + 200, + right: Math.random() * 100 + 200, + left: Math.random() * 50, + top: Math.random() * 50, + }; + return { ...data, toJSON: () => data } as DOMRect; +}; + +const elementRefMock = { + nativeElement: { + getBoundingClientRect: jest.fn().mockImplementation(generateRandomDOMRect), + }, +}; + function setActiveTour(tourId: string): void { Object.defineProperty(tourServiceMock, 'activeTour$', { value: of(tourId) }); } @@ -49,17 +74,43 @@ const sut = async ( ): Promise> => { const result = await render(TourStepDemoComponent, { imports: [IonButtonModule, IonTourModule, IonPopoverModule], - providers: [{ provide: IonTourService, useValue: tourServiceMock }], + providers: [ + { provide: ElementRef, useValue: elementRefMock }, + { provide: IonTourService, useValue: tourServiceMock }, + ], componentProperties: { ...DEFAULT_PROPS, ...props, }, }); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); result.fixture.detectChanges(); return result; }; +const sutResizingHostDemo = (): void => { + TestBed.configureTestingModule({ + imports: [IonButtonModule, IonTourModule, IonPopoverModule], + declarations: [TourResizingHostComponent], + providers: [ + IonTourService, + IonTourStepDirective, + IonPositionService, + ViewContainerRef, + ChangeDetectorRef, + { provide: ElementRef, useValue: elementRefMock }, + ], + }); + + TestBed.get(IonTourStepDirective)['hostPositionChanged'] = jest + .fn() + .mockReturnValue(true); + + TestBed.createComponent(TourResizingHostComponent).detectChanges(); + + jest.runOnlyPendingTimers(); +}; + describe('IonTourStepDirective', () => { afterEach(() => { jest.clearAllMocks(); @@ -103,6 +154,18 @@ describe('IonTourStepDirective', () => { expect(screen.queryByText(newlabel)).toBeInTheDocument(); }); + it('should reposition popover when host position changes', async () => { + sutResizingHostDemo(); + + const directive = TestBed.get(IonTourStepDirective); + const spy = jest.spyOn(directive, 'repositionPopover'); + directive.observeHostPosition(); + + jest.runOnlyPendingTimers(); + + expect(spy).toHaveBeenCalled(); + }); + describe('popover actions', () => { it('should render a default previous button', async () => { const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index 66b9788d4..f51adc10e 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -28,6 +28,7 @@ import { IonPositionService } from '../position/position.service'; import { SafeAny } from '../utils/safe-any'; import { generatePositionCallback } from './tour-position.calculator'; import { IonTourService } from './tour.service'; +import { isEqual } from 'lodash'; @Directive({ selector: '[ionTourStep]' }) export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { @@ -56,7 +57,7 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { private isTourActive = false; private destroy$ = new Subject(); - private observer: NodeJS.Timer; + private interval: ReturnType; private hostPosition: DOMRect; constructor( @@ -107,20 +108,21 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.tourService.removeStep(this.ionStepId); this.destroy$.next(); this.destroy$.complete(); - if (this.observer) { - clearInterval(this.observer); + if (this.interval) { + clearInterval(this.interval); } } private observeHostPosition(): void { this.ngZone.runOutsideAngular(() => { - this.observer = setInterval(() => { + const interval30FPSinMs = 1000 / 30; + this.interval = setInterval(() => { this.ngZone.run(() => { if (this.hostPositionChanged()) { this.repositionPopover(); } }); - }, 0); + }, interval30FPSinMs); }); } @@ -223,9 +225,9 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { } private hostPositionChanged(): boolean { - return ( - this.elementRef.nativeElement.getBoundingClientRect().toJSON() !== - this.hostPosition.toJSON() + return !isEqual( + this.hostPosition, + this.elementRef.nativeElement.getBoundingClientRect() ); } diff --git a/projects/ion/src/lib/tour/tour.service.spec.ts b/projects/ion/src/lib/tour/tour.service.spec.ts index 09feab374..bbdbc8cff 100644 --- a/projects/ion/src/lib/tour/tour.service.spec.ts +++ b/projects/ion/src/lib/tour/tour.service.spec.ts @@ -125,7 +125,7 @@ describe('IonTourService', () => { const updatedStep = { ...step1, - target: { toJSON: () => ({ ...TARGET_MOCK, x: 400 }) }, + getTarget: () => ({ toJSON: () => ({ ...TARGET_MOCK, x: 400 }) }), } as IonTourStepProps; service.saveStep(step1); @@ -134,6 +134,7 @@ describe('IonTourService', () => { expect(service.currentStep.value).toEqual(step1); service.saveStep(updatedStep); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(updatedStep); }); }); @@ -258,13 +259,14 @@ describe('IonTourService', () => { const spy = jest.spyOn(step2.ionOnPrevStep, 'emit'); service.prevStep(); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(step1); expect(spy).toHaveBeenCalledTimes(1); }); it('should finish the tour if there is no next step and nextStep is called', () => { - const [step] = stepsMock; + const step = { ...stepsMock[0], ionNextStepId: undefined }; service.saveStep(step); service.start({ tourId: step.ionTourId }); @@ -275,8 +277,8 @@ describe('IonTourService', () => { service.nextStep(); jest.runAllTimers(); - expect(service.currentStep.value).toBeUndefined(); - expect(service.activeTour.value).toBeUndefined(); + expect(service.currentStep.value).toBeNull(); + expect(service.activeTour.value).toBeNull(); expect(spyNext).toHaveBeenCalledTimes(1); expect(spyFinish).toHaveBeenCalledTimes(1); }); @@ -291,6 +293,7 @@ describe('IonTourService', () => { const spyPrev = jest.spyOn(step.ionOnPrevStep, 'emit'); const spyFinish = jest.spyOn(step.ionOnFinishTour, 'emit'); service.prevStep(); + jest.runAllTimers(); expect(service.currentStep.value).toBeNull(); expect(service.activeTour.value).toBeNull(); diff --git a/projects/ion/src/lib/tour/tour.service.ts b/projects/ion/src/lib/tour/tour.service.ts index 33856fb59..e2f426096 100644 --- a/projects/ion/src/lib/tour/tour.service.ts +++ b/projects/ion/src/lib/tour/tour.service.ts @@ -59,7 +59,7 @@ export class IonTourService { if ( current && current.ionStepId === step.ionStepId && - !isEqual(step.getTarget().toJSON(), current.getTarget().toJSON()) + !isEqual(step.getTarget(), current.getTarget()) ) { this.navigateToStep(step); } @@ -105,13 +105,13 @@ export class IonTourService { public prevStep(): void { const currentStep = this.currentStep.getValue(); + currentStep.ionOnPrevStep.emit(); + if (!currentStep.ionPrevStepId) { this.finish(); return; } - currentStep.ionOnPrevStep.emit(); - setTimeout(() => { const prevStep = this._tours[this.activeTourId].get( currentStep.ionPrevStepId @@ -125,13 +125,13 @@ export class IonTourService { public nextStep(): void { const currentStep = this.currentStep.getValue(); + currentStep.ionOnNextStep.emit(); + if (!currentStep.ionNextStepId) { this.finish(); return; } - currentStep.ionOnNextStep.emit(); - setTimeout(() => { const nextStep = this._tours[this.activeTourId].get( currentStep.ionNextStepId diff --git a/stories/TourResizing.stories.ts b/stories/TourResizing.stories.ts new file mode 100644 index 000000000..3986b6de1 --- /dev/null +++ b/stories/TourResizing.stories.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +import { Meta, Story } from '@storybook/angular'; + +import { IonTourModule } from '../projects/ion/src/lib/tour'; +import { IonSharedModule } from '../projects/ion/src/public-api'; +import { TourResizingHostComponent } from '../projects/ion/src/lib/tour/mocks/resizing-host-demo.component'; + +const Template: Story = ( + args: TourResizingHostComponent +) => ({ + component: TourResizingHostComponent, + props: args, + moduleMetadata: { + declarations: [TourResizingHostComponent], + imports: [CommonModule, IonSharedModule, IonTourModule], + entryComponents: [TourResizingHostComponent], + }, +}); + +export const HostResizing = Template.bind({}); +HostResizing.args = { + ionStepTitle: 'Title Example', + ionStepBody: 'You can change the props of this step in Storybook controls', + ionPrevStepBtn: { label: 'Close' }, + ionNextStepBtn: { label: 'Finish' }, + ionStepMarginToContent: 5, + ionStepBackdropPadding: 5, +}; + +export default { + title: 'Ion/Data Display/Tour', + component: TourResizingHostComponent, +} as Meta; From cd21a299c15bec39f0c38a4756bfa4933d3ccdf1 Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Tue, 10 Dec 2024 10:19:28 -0300 Subject: [PATCH 05/11] perf: updating backdrop only then step change --- .../tour-backdrop.component.spec.ts | 4 +- .../tour-backdrop/tour-backdrop.component.ts | 52 ++++++++++++------- .../ion/src/lib/tour/tour-step.directive.ts | 11 ++-- .../ion/src/lib/tour/tour.service.spec.ts | 2 +- projects/ion/src/lib/tour/tour.service.ts | 10 ++-- 5 files changed, 45 insertions(+), 34 deletions(-) diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts index 1a53da1a2..57fc74fa1 100644 --- a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts @@ -36,9 +36,7 @@ describe('IonTourBackdropComponent', () => { it('should render with custom class', async () => { const ionStepBackdropCustomClass = 'custom-class'; - await sut({ - currentStep: { ...STEP_MOCK, ionStepBackdropCustomClass }, - }); + await sut({ currentStep: { ...STEP_MOCK, ionStepBackdropCustomClass } }); expect(screen.queryByTestId('ion-tour-backdrop')).toHaveClass( ionStepBackdropCustomClass diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts index 22cbb842e..301db42a3 100644 --- a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; import { IonTourStepProps } from '../../core/types'; @@ -9,20 +9,46 @@ import { IonTourStepProps } from '../../core/types'; styleUrls: ['./tour-backdrop.component.scss'], }) export class IonTourBackdropComponent implements OnInit { - @Input() currentStep: IonTourStepProps | null = null; @Input() isActive = false; + public currentStep: IonTourStepProps | null = null; public inTransition = true; + public clipPath: SafeStyle = ''; - public get clipPath(): SafeStyle { + constructor( + private sanitizer: DomSanitizer, + private cdr: ChangeDetectorRef + ) {} + + public ngOnInit(): void { + setTimeout(() => (this.inTransition = false)); + } + + public updateStep(step: IonTourStepProps | null): void { + this.currentStep = step; + this.updateClipPath(); + } + + public performFinalTransition(callback: () => void): void { + const transitionDuration = 400; + this.inTransition = true; + + setTimeout(() => { + this.inTransition = false; + callback(); + }, transitionDuration); + } + + private updateClipPath(): void { if (!this.currentStep) { - return ''; + this.clipPath = ''; + return; } const { getTarget, ionStepBackdropPadding: padding } = this.currentStep; const { top, left, bottom, right } = getTarget(); - return this.sanitizer.bypassSecurityTrustStyle(`polygon( + this.clipPath = this.sanitizer.bypassSecurityTrustStyle(`polygon( 0 0, 0 100%, ${left - padding}px 100%, @@ -34,21 +60,7 @@ export class IonTourBackdropComponent implements OnInit { 100% 100%, 100% 0 )`); - } - - constructor(private sanitizer: DomSanitizer) {} - - public ngOnInit(): void { - setTimeout(() => (this.inTransition = false)); - } - public performFinalTransition(callback: () => void): void { - const transitionDuration = 400; - this.inTransition = true; - - setTimeout(() => { - this.inTransition = false; - callback(); - }, transitionDuration); + this.cdr.detectChanges(); } } diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index f51adc10e..ae3e95741 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -225,9 +225,14 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { } private hostPositionChanged(): boolean { - return !isEqual( - this.hostPosition, - this.elementRef.nativeElement.getBoundingClientRect() + const newPosition = this.elementRef.nativeElement.getBoundingClientRect(); + return ( + this.hostPosition && + newPosition && + this.hostPosition.x === newPosition.x && + this.hostPosition.y === newPosition.y && + this.hostPosition.width === newPosition.width && + this.hostPosition.height === newPosition.height ); } diff --git a/projects/ion/src/lib/tour/tour.service.spec.ts b/projects/ion/src/lib/tour/tour.service.spec.ts index bbdbc8cff..7b29c5849 100644 --- a/projects/ion/src/lib/tour/tour.service.spec.ts +++ b/projects/ion/src/lib/tour/tour.service.spec.ts @@ -26,7 +26,7 @@ const backdropComponentMock = { hostView: {}, location: { nativeElement: document.createElement('div') }, changeDetectorRef: { detectChanges: jest.fn() }, - instance: { performFinalTransition }, + instance: { performFinalTransition, updateStep: jest.fn() }, destroy: jest.fn(), }; diff --git a/projects/ion/src/lib/tour/tour.service.ts b/projects/ion/src/lib/tour/tour.service.ts index e2f426096..4a652f710 100644 --- a/projects/ion/src/lib/tour/tour.service.ts +++ b/projects/ion/src/lib/tour/tour.service.ts @@ -55,12 +55,7 @@ export class IonTourService { } const current = this.currentStep.value; - - if ( - current && - current.ionStepId === step.ionStepId && - !isEqual(step.getTarget(), current.getTarget()) - ) { + if (current && current.ionStepId === step.ionStepId) { this.navigateToStep(step); } @@ -154,6 +149,7 @@ export class IonTourService { private navigateToStep(step: IonTourStepProps): void { this.currentStep.next(step); + console.log('step', step); } private createBackdrop(): void { @@ -180,7 +176,7 @@ export class IonTourService { .pipe(takeUntil(this.destroyBackdrop$)) .subscribe((step) => { if (this.backdropRef) { - this.backdropRef.instance.currentStep = step; + this.backdropRef.instance.updateStep(step); } }); From e7045fe842d8cc58e8dde810e1bd4bc7edc36d55 Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Tue, 10 Dec 2024 10:30:00 -0300 Subject: [PATCH 06/11] fix: host change validation --- projects/ion/src/lib/tour/tour-step.directive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index ae3e95741..6cb6dc945 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -226,7 +226,7 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { private hostPositionChanged(): boolean { const newPosition = this.elementRef.nativeElement.getBoundingClientRect(); - return ( + return !( this.hostPosition && newPosition && this.hostPosition.x === newPosition.x && From 4942a995f554662440ad9d9bb1c75e118ef38c6a Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Tue, 10 Dec 2024 10:31:04 -0300 Subject: [PATCH 07/11] refactor: removing logs --- projects/ion/src/lib/tour/tour.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/ion/src/lib/tour/tour.service.ts b/projects/ion/src/lib/tour/tour.service.ts index 4a652f710..4eb07da6f 100644 --- a/projects/ion/src/lib/tour/tour.service.ts +++ b/projects/ion/src/lib/tour/tour.service.ts @@ -149,7 +149,6 @@ export class IonTourService { private navigateToStep(step: IonTourStepProps): void { this.currentStep.next(step); - console.log('step', step); } private createBackdrop(): void { From 58f4efa219b9e8026990d3b0f9e719723c300278 Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Tue, 10 Dec 2024 10:33:09 -0300 Subject: [PATCH 08/11] docs: updating directive params --- stories/TourDocs.stories.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stories/TourDocs.stories.mdx b/stories/TourDocs.stories.mdx index 71fecdc1a..278323c84 100644 --- a/stories/TourDocs.stories.mdx +++ b/stories/TourDocs.stories.mdx @@ -40,10 +40,9 @@ Passos para usar o tour em uma tela: - `ionStepMarginToContent` é o espaçamento entre o balão de fala e o inicio da borda do backdrop; - `ionStepBackdropPadding` é o espaçamento que fica entre o elemento destacado e o inicio da borda escurecida do backdrop; - `ionStepCustomClass` é uma classe customizada que pode ser aplicada ao balão de fala da etapa; -- `ionStepBackdropCustomClas` é uma classe customizada que pode ser aplicada ao backdrop da etapa; +- `ionStepBackdropCustomClass` é uma classe customizada que pode ser aplicada ao backdrop da etapa; - `ionOnPrevStep` é um evento que será disparado ao clicar no botão de voltar para a etapa anterior; - `ionOnNextStep` é um evento que será disparado ao clicar no botão de avançar para a próxima etapa; -- `ionOnCloseStep` é um evento que será disparado ao clicar no botão de fechar o tour. 2. No arquivo .ts do seu componente, use o serviço `IonTourService` para iniciar o tour: From 0db3425d88c91e841f3eceb11157cc6ad9c6af2d Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Tue, 10 Dec 2024 11:06:06 -0300 Subject: [PATCH 09/11] test: increasing coverage --- .../tour/tour-backdrop/tour-backdrop.component.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts index 57fc74fa1..2d7215fc7 100644 --- a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts @@ -57,6 +57,14 @@ describe('IonTourBackdropComponent', () => { expect(screen.queryByTestId('ion-tour-backdrop')).not.toBeInTheDocument(); }); + it('should backdrop with empty clip-path when the step is not active', async () => { + const { fixture } = await sut(); + fixture.componentInstance.updateStep(null); + expect(screen.queryByTestId('ion-tour-backdrop')).toHaveStyle({ + clipPath: '', + }); + }); + it('should stop rendering when performFinalTransition is called', async () => { jest.useFakeTimers(); const { fixture } = await sut({ inTransition: true }); From 2ae0d2f69d68fb2f1c0d49baa77868905f83a7ad Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Tue, 10 Dec 2024 14:52:57 -0300 Subject: [PATCH 10/11] refactor: lint --- .../lib/tour/mocks/resizing-host-demo.component.ts | 12 ++++++------ .../ion/src/lib/tour/tour-step.directive.spec.ts | 6 +++--- projects/ion/src/lib/tour/tour-step.directive.ts | 1 - projects/ion/src/lib/tour/tour.service.spec.ts | 4 +++- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts b/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts index 4250d2052..1b5a53961 100644 --- a/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts +++ b/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts @@ -60,6 +60,12 @@ export class TourResizingHostComponent implements OnInit, OnDestroy { this.animateButtonSize(); } + public ngOnDestroy(): void { + if (this.interval) { + clearInterval(this.interval); + } + } + private animateButtonSize(): void { this.interval = setInterval(() => { this.top = this.getRandomNumber(0, 700); @@ -71,10 +77,4 @@ export class TourResizingHostComponent implements OnInit, OnDestroy { private getRandomNumber(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } - - public ngOnDestroy(): void { - if (this.interval) { - clearInterval(this.interval); - } - } } diff --git a/projects/ion/src/lib/tour/tour-step.directive.spec.ts b/projects/ion/src/lib/tour/tour-step.directive.spec.ts index 007975b03..4126d03ee 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.spec.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectorRef, ElementRef, ViewContainerRef } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { fireEvent, render, @@ -13,12 +13,12 @@ import { EMPTY, of } from 'rxjs'; import { IonButtonModule } from '../button/button.module'; import { IonTourStepProps } from '../core/types'; import { IonPopoverModule } from '../popover/popover.module'; +import { IonPositionService } from '../position/position.service'; import { TourResizingHostComponent } from './mocks/resizing-host-demo.component'; import { TourStepDemoComponent } from './mocks/tour-step-props.component'; +import { IonTourStepDirective } from './tour-step.directive'; import { IonTourModule } from './tour.module'; import { IonTourService } from './tour.service'; -import { IonTourStepDirective } from './tour-step.directive'; -import { IonPositionService } from '../position/position.service'; const DEFAULT_PROPS: Partial = { ionTourId: 'demo-tour', diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index 6cb6dc945..7c6375a07 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -28,7 +28,6 @@ import { IonPositionService } from '../position/position.service'; import { SafeAny } from '../utils/safe-any'; import { generatePositionCallback } from './tour-position.calculator'; import { IonTourService } from './tour.service'; -import { isEqual } from 'lodash'; @Directive({ selector: '[ionTourStep]' }) export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { diff --git a/projects/ion/src/lib/tour/tour.service.spec.ts b/projects/ion/src/lib/tour/tour.service.spec.ts index 7b29c5849..b9f0f2644 100644 --- a/projects/ion/src/lib/tour/tour.service.spec.ts +++ b/projects/ion/src/lib/tour/tour.service.spec.ts @@ -125,7 +125,9 @@ describe('IonTourService', () => { const updatedStep = { ...step1, - getTarget: () => ({ toJSON: () => ({ ...TARGET_MOCK, x: 400 }) }), + getTarget: () => ({ + toJSON: (): DOMRect => ({ ...TARGET_MOCK, x: 400 }), + }), } as IonTourStepProps; service.saveStep(step1); From 8bc72f1089d55bf266600bf8aaff597aa779e946 Mon Sep 17 00:00:00 2001 From: vinicius-guedes-brisa Date: Tue, 10 Dec 2024 15:18:51 -0300 Subject: [PATCH 11/11] fix: clear isStepSelected when finish tour --- projects/ion/src/lib/tour/tour-step.directive.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index 7c6375a07..a35a8fbdf 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -91,6 +91,8 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.isStepSelected = isSameStep; this.checkPopoverVisibility(); } + } else { + this.isStepSelected = false; } }); }