Skip to content

Commit

Permalink
NAS-131790 / 25.04 / Add option to add confirmation requirement (#11053)
Browse files Browse the repository at this point in the history
  • Loading branch information
RehanY147 authored Nov 22, 2024
1 parent 6d0d1d7 commit b041a5f
Show file tree
Hide file tree
Showing 22 changed files with 309 additions and 30 deletions.
2 changes: 2 additions & 0 deletions src/app/modules/slide-ins/chained-component-ref.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Type } from '@angular/core';
import { Observable } from 'rxjs';
import { ChainedComponentResponse as ChainedResponse } from 'app/services/chained-slide-in.service';

export class ChainedRef<T> {
Expand All @@ -12,4 +13,5 @@ export class ChainedRef<T> {
*/
swap?: (component: Type<unknown>, wide: boolean, data?: unknown) => void;
getData: () => T;
requireConfirmationWhen: (confirm: () => Observable<boolean>) => void;
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,154 @@
import { A11yModule } from '@angular/cdk/a11y';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { ElementRef, Renderer2 } from '@angular/core';
import {
fakeAsync, discardPeriodicTasks, tick,
} from '@angular/core/testing';
import { tick, fakeAsync, discardPeriodicTasks } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonHarness } from '@angular/material/button/testing';
import { Spectator, createComponentFactory, mockProvider } from '@ngneat/spectator/jest';
import { MockComponent } from 'ng-mocks';
import {
Subject, of,
} from 'rxjs';
import { Subject, of } from 'rxjs';
import { mockApi, mockCall } from 'app/core/testing/utils/mock-api.utils';
import { mockAuth } from 'app/core/testing/utils/mock-auth.utils';
import { CloudSyncProviderName } from 'app/enums/cloudsync-provider.enum';
import { Direction } from 'app/enums/direction.enum';
import { JobState } from 'app/enums/job-state.enum';
import { TransferMode } from 'app/enums/transfer-mode.enum';
import { CloudSyncTaskUi } from 'app/interfaces/cloud-sync-task.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { CloudCredentialsSelectComponent } from 'app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component';
import { ChainedRef } from 'app/modules/slide-ins/chained-component-ref';
import { SlideIn2Component } from 'app/modules/slide-ins/components/slide-in2/slide-in2.component';
import { CloudSyncFormComponent } from 'app/pages/data-protection/cloudsync/cloudsync-form/cloudsync-form.component';
import { TransferModeExplanationComponent } from 'app/pages/data-protection/cloudsync/transfer-mode-explanation/transfer-mode-explanation.component';
import { ChainedComponentResponse, ChainedSlideInService } from 'app/services/chained-slide-in.service';
import { FilesystemService } from 'app/services/filesystem.service';

describe('IxSlideIn2Component', () => {
const existingTask = {
id: 1,
description: 'New Cloud Sync Task',
direction: Direction.Push,
path: '/mnt/my pool',
attributes: { folder: '/test/' } as Record<string, string>,
enabled: false,
transfer_mode: TransferMode.Copy,
encryption: true,
filename_encryption: true,
encryption_password: 'password',
encryption_salt: 'salt',
args: '',
post_script: 'test post-script',
pre_script: 'test pre-script',
snapshot: false,
bwlimit: [
{ time: '13:00', bandwidth: 1024 },
{ time: '15:00' },
],
include: [],
exclude: [],
transfers: 2,
create_empty_src_dirs: true,
follow_symlinks: true,
credentials: {
id: 2,
name: 'test2',
provider: 'MEGA',
attributes: { user: 'login', pass: 'password' } as Record<string, string>,
},
schedule: {
minute: '0',
hour: '0',
dom: '*',
month: '*',
dow: '0',
},
locked: false,
job: null,
credential: 'test2',
cron_schedule: 'Disabled',
frequency: 'At 00:00, only on Sunday',
next_run_time: 'Disabled',
next_run: 'Disabled',
state: { state: JobState.Pending },
} as CloudSyncTaskUi;
const close$ = new Subject<ChainedComponentResponse>();
let spectator: Spectator<SlideIn2Component>;
let loader: HarnessLoader;
const createComponent = createComponentFactory({
component: SlideIn2Component,
imports: [
A11yModule,
],
declarations: [
MockComponent(CloudSyncFormComponent),
CloudSyncFormComponent,
CloudCredentialsSelectComponent,
ReactiveFormsModule,
TransferModeExplanationComponent,
],
providers: [
mockAuth(),
mockProvider(DialogService, {
jobDialog: jest.fn(() => ({
afterClosed: jest.fn(() => of(true)),
})),
}),
mockApi([
mockCall('cloudsync.create', existingTask),
mockCall('cloudsync.update', existingTask),
mockCall('cloudsync.credentials.query', [
{
id: 1,
name: 'test1',
provider: CloudSyncProviderName.Http,
attributes: {
url: 'http',
},
},
{
id: 2,
name: 'test2',
provider: CloudSyncProviderName.Mega,
attributes: {
user: 'login',
pass: 'password',
},
},
]),
mockCall('cloudsync.providers', [{
name: CloudSyncProviderName.Http,
title: 'Http',
buckets: false,
bucket_title: 'Bucket',
task_schema: [],
credentials_schema: [],
credentials_oauth: null,
},
{
name: CloudSyncProviderName.Mega,
title: 'Mega',
buckets: false,
bucket_title: 'Bucket',
task_schema: [],
credentials_schema: [],
credentials_oauth: null,
}]),
]),
mockProvider(FilesystemService),
mockProvider(ChainedRef, {
close: jest.fn(),
requireConfirmationWhen: jest.fn(),
getData: jest.fn(() => undefined),
swap: jest.fn(),
}),
mockProvider(ElementRef),
mockProvider(Renderer2),
mockProvider(ChainedSlideInService, {
isTopComponentWide$: of(false),
popComponent: jest.fn(),
swapComponent: jest.fn(),
open: jest.fn(() => of()),
components$: of([]),
}),
mockProvider(DialogService, {
confirm: jest.fn(() => of(true)),
}),
],
});
Expand Down Expand Up @@ -58,6 +177,7 @@ describe('IxSlideIn2Component', () => {
lastIndex: 0,
},
});
loader = TestbedHarnessEnvironment.loader(spectator.fixture);
tick(10);
}

Expand All @@ -78,4 +198,49 @@ describe('IxSlideIn2Component', () => {
const form = spectator.query(CloudSyncFormComponent);
expect(form).toExist();
}));

it('asks for confirmation when require confirmation method setup', fakeAsync(() => {
setupComponent();
const form = spectator.query(CloudSyncFormComponent);
form.form.markAsDirty();
spectator.detectChanges();
const backdrop = spectator.query('.ix-slide-in2-background');
backdrop.dispatchEvent(new Event('click'));

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({
title: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to close?',
cancelText: 'No',
buttonText: 'Yes',
buttonColor: 'red',
hideCheckbox: true,
});
discardPeriodicTasks();
}));

it('doesnt ask for confirmation when form is saved', fakeAsync(async () => {
setupComponent();
const form = spectator.query(CloudSyncFormComponent);
form.form.patchValue({
description: 'New Cloud Sync Task',
credentials: 1,
});

const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await saveButton.click();
form.form.markAsDirty();
spectator.detectChanges();
const backdrop = spectator.query('.ix-slide-in2-background');
backdrop.dispatchEvent(new Event('click'));

expect(spectator.inject(DialogService).confirm).not.toHaveBeenCalledWith({
title: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to close?',
cancelText: 'No',
buttonText: 'Yes',
buttonColor: 'red',
hideCheckbox: true,
});
discardPeriodicTasks();
}));
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ import {
ViewContainerRef,
} from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { cloneDeep } from 'lodash-es';
import { Subscription, timer } from 'rxjs';
import {
filter, Observable, of, Subscription, switchMap, timer,
} from 'rxjs';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { ChainedRef } from 'app/modules/slide-ins/chained-component-ref';
import {
ChainedComponentResponse,
Expand All @@ -37,6 +41,7 @@ export class SlideIn2Component implements OnInit, OnDestroy {
@Input() index: number;
@Input() lastIndex: number;
@ViewChild('chainedBody', { static: true, read: ViewContainerRef }) slideInBody: ViewContainerRef;
private needConfirmation: () => Observable<boolean>;

@HostListener('document:keydown.escape') onKeydownHandler(): void {
this.onBackdropClicked();
Expand All @@ -54,6 +59,8 @@ export class SlideIn2Component implements OnInit, OnDestroy {

constructor(
private el: ElementRef,
private dialogService: DialogService,
private translate: TranslateService,
private renderer: Renderer2,
private chainedSlideInService: ChainedSlideInService,
private cdr: ChangeDetectorRef,
Expand Down Expand Up @@ -90,9 +97,17 @@ export class SlideIn2Component implements OnInit, OnDestroy {
if (!this.element || !this.isSlideInOpen) {
return;
}
this.componentInfo.close$.next({ response: false, error: null });
this.componentInfo.close$.complete();
this.closeSlideIn();

this.canCloseSlideIn().pipe(
filter(Boolean),
untilDestroyed(this),
).subscribe({
next: () => {
this.componentInfo.close$.next({ response: false, error: null });
this.componentInfo.close$.complete();
this.closeSlideIn();
},
});
}

closeSlideIn(): void {
Expand Down Expand Up @@ -147,27 +162,65 @@ export class SlideIn2Component implements OnInit, OnDestroy {
{
provide: ChainedRef<D>,
useValue: {
close: (response: ChainedComponentResponse) => {
this.componentInfo.close$.next(response);
this.componentInfo.close$.complete();
this.closeSlideIn();
close: (response: ChainedComponentResponse): void => {
(!response.response ? this.canCloseSlideIn() : of(true)).pipe(
filter(Boolean),
untilDestroyed(this),
).subscribe({
next: () => {
this.componentInfo.close$.next(response);
this.componentInfo.close$.complete();
this.closeSlideIn();
},
});
},
swap: (component: Type<unknown>, wide = false, incomingComponentData?: unknown) => {
this.chainedSlideInService.swapComponent({
swapComponentId: this.componentInfo.id,
component,
wide,
data: incomingComponentData,
swap: (component: Type<unknown>, wide = false, incomingComponentData?: unknown): void => {
this.canCloseSlideIn().pipe(
filter(Boolean),
untilDestroyed(this),
).subscribe({
next: () => {
this.chainedSlideInService.swapComponent({
swapComponentId: this.componentInfo.id,
component,
wide,
data: incomingComponentData,
});
this.closeSlideIn();
},
});
this.closeSlideIn();
},
getData: (): D => {
return cloneDeep(data);
},
requireConfirmationWhen: (needConfirmation: () => Observable<boolean>): void => {
this.needConfirmation = needConfirmation;
},
} as ChainedRef<D>,
},
],
});
this.slideInBody.createComponent<T>(componentType, { injector });
}

private canCloseSlideIn(): Observable<boolean> {
if (!this.needConfirmation) {
return of(true);
}

return this.needConfirmation().pipe(
switchMap((needConfirmation) => (needConfirmation ? this.showConfirmDialog() : of(true))),
);
}

private showConfirmDialog(): Observable<boolean> {
return this.dialogService.confirm({
title: this.translate.instant('Unsaved Changes'),
message: this.translate.instant('You have unsaved changes. Are you sure you want to close?'),
cancelText: this.translate.instant('No'),
buttonText: this.translate.instant('Yes'),
buttonColor: 'red',
hideCheckbox: true,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe('SshConnectionFormComponent', () => {
close: closeChainedRef,
getData: getNoData,
swap: jest.fn(),
requireConfirmationWhen: jest.fn(),
} as ChainedRef<KeychainSshCredentials>),
],
});
Expand All @@ -77,6 +78,7 @@ describe('SshConnectionFormComponent', () => {
close: closeChainedRef,
getData,
swap: jest.fn(),
requireConfirmationWhen: jest.fn(),
} as ChainedRef<KeychainSshCredentials>),
],
});
Expand Down
Loading

0 comments on commit b041a5f

Please sign in to comment.