Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,13 @@ describe('DotWizardComponent', () => {
expect(transform).toEqual('translateX(-400px)');
}));

it('should notify the service on dismiss so leaked subscriptions unsubscribe', fakeAsync(() => {
const cancelSpy = jest.spyOn(dotWizardService, 'cancel');
spectator.component.close();
tick(0);
expect(cancelSpy).toHaveBeenCalledTimes(1);
}));

it('should update transform property on previous', fakeAsync(() => {
form1.valid.emit(true);
form2.valid.emit(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,16 @@ export class DotWizardComponent implements AfterViewInit {
* @memberof DotWizardComponent
*/
close(): void {
const wasOpen = !!this.$data();
this.$data.set(null);
this.#currentStep = 0;
this.updateTransform();
this.$stepsVisible.set(false);
if (wasOpen) {
// Idempotent on the submit path: sendValue() nullifies currentOutput
// before calling close(), so cancel() becomes a no-op there.
this.#dotWizardService.cancel();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,50 @@ describe('DotWizardService', () => {
service.output$(mockOutput);
expect(outputData).toEqual(mockOutput);
});

it('should complete the stream without emitting when cancel is called', () => {
const next = jest.fn();
const complete = jest.fn();
service.open(mockWizardInput).subscribe({ next, complete });
service.cancel();
expect(next).not.toHaveBeenCalled();
expect(complete).toHaveBeenCalledTimes(1);
});

it('should not deliver output from a new open() to a previous (cancelled) subscription', () => {
const firstNext = jest.fn();
const secondNext = jest.fn();

service.open(mockWizardInput).subscribe(firstNext);
service.cancel(); // user dismissed the first wizard

service.open(mockWizardInput).subscribe(secondNext);
service.output$(mockOutput); // user sends on the second wizard

expect(firstNext).not.toHaveBeenCalled();
expect(secondNext).toHaveBeenCalledWith(mockOutput);
});

it('should complete a previous stream when open() is called again without cancel', () => {
const firstNext = jest.fn();
const firstComplete = jest.fn();
const secondNext = jest.fn();

service.open(mockWizardInput).subscribe({ next: firstNext, complete: firstComplete });
service.open(mockWizardInput).subscribe(secondNext);
service.output$(mockOutput);

expect(firstNext).not.toHaveBeenCalled();
expect(firstComplete).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledWith(mockOutput);
});

it('should complete after output$ so take(1) consumers unsubscribe cleanly', () => {
const next = jest.fn();
const complete = jest.fn();
service.open(mockWizardInput).subscribe({ next, complete });
service.output$(mockOutput);
expect(next).toHaveBeenCalledWith(mockOutput);
expect(complete).toHaveBeenCalledTimes(1);
});
});
40 changes: 27 additions & 13 deletions core-web/libs/data-access/src/lib/dot-wizard/dot-wizard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,51 @@ import { Injectable } from '@angular/core';

import { DotWizardInput } from '@dotcms/dotcms-models';

type WizardOutput = { [key: string]: string | string[] };

@Injectable({
providedIn: 'root'
})
export class DotWizardService {
private input: Subject<DotWizardInput> = new Subject<DotWizardInput>();
private output: Subject<{ [key: string]: string | string[] }> = new Subject<{
[key: string]: string | string[];
}>();
private currentOutput: Subject<WizardOutput> | null = null;

get showDialog$(): Observable<DotWizardInput> {
return this.input.asObservable();
}

/**
* Notify the data collected in wizard.
* @param {{ [key: string]: string | string[] }} form
* @memberof DotWizardService
* Emit the data collected by the wizard and close the current stream.
* Called by the wizard component when the user accepts/sends.
*/
output$(form: WizardOutput): void {
this.currentOutput?.next(form);
this.currentOutput?.complete();
this.currentOutput = null;
}

/**
* Close the current stream without emitting. Called by the wizard
* component when the user cancels or dismisses the dialog so that
* pending subscriptions unsubscribe instead of leaking into the next
* wizard invocation.
*/
output$(form: { [key: string]: string | string[] }): void {
this.output.next(form);
cancel(): void {
this.currentOutput?.complete();
this.currentOutput = null;
}

/**
* Send the wizard data to in input subscription and waits for the output
* @param {DotWizardInput} data
* @returns Observable<{ [key: string]: string }>
* @memberof DotWizardService
* Show the wizard with the given input and return an observable that
* emits once when the user submits and completes, or completes without
* emitting if the user cancels. Each call returns an isolated stream.
*/
open<T = { [key: string]: string }>(data: DotWizardInput): Observable<T> {
this.currentOutput?.complete();
this.currentOutput = new Subject<WizardOutput>();
const output$ = this.currentOutput.asObservable() as unknown as Observable<T>;
this.input.next(data);

return this.output.asObservable() as Observable<T>;
return output$;
}
}
Loading