Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: ensure the component fully renders before proceeding to the next step in ionTour #1222

2 changes: 1 addition & 1 deletion projects/ion/src/lib/core/types/tour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface IonTourStepProps {
ionOnPrevStep?: EventEmitter<void>;
ionOnNextStep?: EventEmitter<void>;
ionOnFinishTour?: EventEmitter<void>;
target?: DOMRect;
getTarget?: () => DOMRect;
}

export interface IonStartTourProps {
Expand Down
80 changes: 80 additions & 0 deletions projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts
Original file line number Diff line number Diff line change
@@ -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: `
<main>
<ion-button
data-testid="demo-resizing-button"
[style.top.px]="top"
[style.left.px]="left"
[style.width.px]="buttonWidth"
label="Demo"
type="secondary"
[expand]="true"
ionTourStep
ionStepId="demo-resizing-step"
ionTourId="demo-resizing-tour"
ionStepTitle="Title Example"
[ionStepBody]="stepBody"
></ion-button>
</main>

<ng-template #stepBody>
<p>
The host is changing its size and position, but the tour should still
work
</p>
</ng-template>
`,
})
export class TourResizingHostComponent implements OnInit, OnDestroy {
public top = 100;
public left = 50;
public buttonWidth = 100;

private interval: ReturnType<typeof setInterval>;

constructor(private readonly ionTourService: IonTourService) {}

public ngOnInit(): void {
this.ionTourService.start();
this.animateButtonSize();
}

public ngOnDestroy(): void {
if (this.interval) {
clearInterval(this.interval);
}
}

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;
}
}
8 changes: 8 additions & 0 deletions projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ export const STEP3_MOCK: IonTourStepProps = {
[ionNextStepId]="step2.ionNextStepId"
[ionStepTitle]="step2.ionStepTitle"
[ionStepBody]="saveStep"
(ionOnNextStep)="markOptionStepAsVisible()"
></ion-button>
<ion-button
*ngIf="isOptionStepVisible"
iconType="option"
type="secondary"
ionTourStep
Expand Down Expand Up @@ -127,9 +129,15 @@ export class TourBasicDemoComponent {
public step2 = STEP2_MOCK;
public step3 = STEP3_MOCK;

public isOptionStepVisible = false;

constructor(private readonly ionTourService: IonTourService) {}

public startTour(): void {
this.ionTourService.start();
}

public markOptionStepAsVisible(): void {
this.isOptionStepVisible = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const sut = async (
};

const STEP_MOCK = {
target: {
getTarget: () => ({
x: 300,
y: 300,
width: 100,
Expand All @@ -24,7 +24,7 @@ const STEP_MOCK = {
right: 400,
left: 300,
top: 300,
} as DOMRect,
}),
} as IonTourStepProps;

describe('IonTourBackdropComponent', () => {
Expand All @@ -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
Expand All @@ -59,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 });
Expand Down
56 changes: 34 additions & 22 deletions projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 { 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(
this.clipPath = this.sanitizer.bypassSecurityTrustStyle(`polygon(
0 0,
0 100%,
${left - padding}px 100%,
Expand All @@ -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();
}
}
73 changes: 68 additions & 5 deletions projects/ion/src/lib/tour/tour-step.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { ChangeDetectorRef, ElementRef, ViewContainerRef } from '@angular/core';
import { 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 { 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 { IonPopoverModule } from '../popover/popover.module';
import userEvent from '@testing-library/user-event';

const DEFAULT_PROPS: Partial<TourStepDemoComponent> = {
ionTourId: 'demo-tour',
Expand All @@ -23,7 +28,7 @@ const DEFAULT_PROPS: Partial<TourStepDemoComponent> = {
ionNextStepBtn: { label: 'Test Next' },
};

const tourServiceMock: Partial<IonTourService> = {
const tourServiceMock = {
saveStep: jest.fn(),
removeStep: jest.fn(),
start: jest.fn(),
Expand All @@ -34,6 +39,26 @@ const tourServiceMock: Partial<IonTourService> = {
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) });
}
Expand All @@ -49,17 +74,43 @@ const sut = async (
): Promise<RenderResult<TourStepDemoComponent>> => {
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();
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading