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;