Skip to content

Commit

Permalink
NAS-132677: Move global target configuration to a slide-in form (#11084)
Browse files Browse the repository at this point in the history
  • Loading branch information
undsoft authored Nov 25, 2024
1 parent 932540e commit f448132
Show file tree
Hide file tree
Showing 134 changed files with 835 additions and 506 deletions.
Original file line number Diff line number Diff line change
@@ -1,54 +1,22 @@
import { Router } from '@angular/router';
import { createDirectiveFactory, SpectatorDirective } from '@ngneat/spectator/jest';
import { MockProvider } from 'ng-mocks';
import { createDirectiveFactory, mockProvider, SpectatorDirective } from '@ngneat/spectator/jest';
import { NavigateAndInteractService } from 'app/directives/navigate-and-interact/navigate-and-interact.service';
import { NavigateAndInteractDirective } from './navigate-and-interact.directive';

describe('NavigateAndInteractDirective', () => {
let spectator: SpectatorDirective<NavigateAndInteractDirective>;
let mockRouter: Router;
const createDirective = createDirectiveFactory({
directive: NavigateAndInteractDirective,
providers: [
MockProvider(Router, {
navigate: jest.fn(() => Promise.resolve(true)),
}),
mockProvider(NavigateAndInteractService),
],
});

beforeEach(() => {
spectator = createDirective('<div ixNavigateAndInteract [navigateRoute]="[\'/some-path\']" navigateHash="testHash"></div>');
mockRouter = spectator.inject(Router);
});

it('should create an instance', () => {
expect(spectator.directive).toBeTruthy();
});

it('should call router.navigate with correct parameters on click', () => {
spectator.dispatchMouseEvent(spectator.element, 'click');
expect(mockRouter.navigate).toHaveBeenCalledWith(['/some-path'], { fragment: 'testHash' });
});

it('should scroll to and highlight the element with the given ID', () => {
const scrollIntoViewMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;

const mockElement = document.createElement('div');
mockElement.id = 'testHash';
document.body.appendChild(mockElement);

const clickSpy = jest.spyOn(HTMLElement.prototype, 'click');

it('calls NavigateAndInteractService.navigateAndInteract when element is clicked', () => {
spectator.dispatchMouseEvent(spectator.element, 'click');

setTimeout(() => {
expect(scrollIntoViewMock).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();

// Clean up
document.body.removeChild(mockElement);
// Restore original scrollIntoView
delete HTMLElement.prototype.scrollIntoView;
}, 0);
expect(spectator.inject(NavigateAndInteractService).navigateAndInteract).toHaveBeenCalledWith(['/some-path'], 'testHash');
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {
Directive, HostListener, input,
} from '@angular/core';
import { Router } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { NavigateAndInteractService } from 'app/directives/navigate-and-interact/navigate-and-interact.service';

@UntilDestroy()
@Directive({
Expand All @@ -13,25 +13,10 @@ export class NavigateAndInteractDirective {
readonly navigateRoute = input.required<string[]>();
readonly navigateHash = input.required<string>();

constructor(private router: Router) {}
constructor(private navigateAndInteract: NavigateAndInteractService) {}

@HostListener('click')
onClick(): void {
this.router.navigate(this.navigateRoute(), { fragment: this.navigateHash() }).then(() => {
const htmlElement = document.getElementById(this.navigateHash());
if (htmlElement) {
this.handleHashScrollIntoView(htmlElement);
}
});
}

private handleHashScrollIntoView(htmlElement: HTMLElement): void {
const highlightedClass = 'highlighted-element';
setTimeout(() => {
htmlElement.scrollIntoView({ block: 'center' });
htmlElement.classList.add(highlightedClass);
htmlElement.click();
}, 150);
setTimeout(() => htmlElement.classList.remove(highlightedClass), 2150);
this.navigateAndInteract.navigateAndInteract(this.navigateRoute(), this.navigateHash());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Router } from '@angular/router';
import {
createServiceFactory,
SpectatorService,
} from '@ngneat/spectator/jest';
import { MockProvider } from 'ng-mocks';
import { NavigateAndInteractService } from 'app/directives/navigate-and-interact/navigate-and-interact.service';

describe('NavigateAndInteractService', () => {
let spectator: SpectatorService<NavigateAndInteractService>;
const createComponent = createServiceFactory({
service: NavigateAndInteractService,
providers: [
MockProvider(Router, {
navigate: jest.fn(() => Promise.resolve(true)),
}),
],
});

beforeEach(() => {
spectator = createComponent();
});

it('should call router.navigate with correct parameters on click', () => {
spectator.service.navigateAndInteract(['/some-path'], 'testHash');
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/some-path'], { fragment: 'testHash' });
});

it('should scroll to and highlight the element with the given ID', () => {
const scrollIntoViewMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;

const mockElement = document.createElement('div');
mockElement.id = 'testHash';
document.body.appendChild(mockElement);

const clickSpy = jest.spyOn(HTMLElement.prototype, 'click');

spectator.service.navigateAndInteract(['/some-path'], 'testHash');

setTimeout(() => {
expect(scrollIntoViewMock).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();

// Clean up
document.body.removeChild(mockElement);
// Restore original scrollIntoView
delete HTMLElement.prototype.scrollIntoView;
}, 0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { WINDOW } from 'app/helpers/window.helper';

@Injectable({
providedIn: 'root',
})
export class NavigateAndInteractService {
constructor(
private router: Router,
@Inject(WINDOW) private window: Window,
) {}

navigateAndInteract(route: string[], hash: string): void {
this.router.navigate(route, { fragment: hash }).then(() => {
setTimeout(() => {
const htmlElement = this.window.document.getElementById(hash);
if (htmlElement) {
this.handleHashScrollIntoView(htmlElement);
}
}, 150);
});
}

private handleHashScrollIntoView(htmlElement: HTMLElement): void {
const highlightedClass = 'highlighted-element';
htmlElement.scrollIntoView({ block: 'center' });
htmlElement.classList.add(highlightedClass);
htmlElement.click();
setTimeout(() => htmlElement.classList.remove(highlightedClass), 2150);
}
}
15 changes: 15 additions & 0 deletions src/app/interfaces/api/api-call-directory.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ import {
FailoverConfig,
FailoverUpdate,
} from 'app/interfaces/failover.interface';
import {
FibreChannelPort,
FibreChannelPortChoices,
FibreChannelPortUpdate,
} from 'app/interfaces/fibre-channel.interface';
import { FileRecord, ListdirQueryParams } from 'app/interfaces/file-record.interface';
import { FileSystemStat, Statfs } from 'app/interfaces/filesystem-stat.interface';
import { FtpConfig, FtpConfigUpdate } from 'app/interfaces/ftp-config.interface';
Expand Down Expand Up @@ -449,6 +454,16 @@ export interface ApiCallDirectory {
'failover.sync_to_peer': { params: [{ reboot?: boolean }]; response: void };
'failover.update': { params: [FailoverUpdate]; response: FailoverConfig };

// Fibre Channel
'fc.capable': { params: []; response: boolean };

// Fibre Channel Port
'fcport.create': { params: [FibreChannelPortUpdate]; response: FibreChannelPort };
'fcport.update': { params: [id: number, update: FibreChannelPortUpdate]; response: FibreChannelPort };
'fcport.delete': { params: [id: number]; response: true };
'fcport.port_choices': { params: [include_used?: boolean]; response: FibreChannelPortChoices };
'fcport.query': { params: QueryParams<FibreChannelPort>; response: FibreChannelPort[] };

// Filesystem
'filesystem.acltemplate.by_path': { params: [AclTemplateByPathParams]; response: AclTemplateByPath[] };
'filesystem.acltemplate.create': { params: [AclTemplateCreateParams]; response: AclTemplateCreateResponse };
Expand Down
17 changes: 17 additions & 0 deletions src/app/interfaces/fibre-channel.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface FibreChannelPort {
id: number;
port: string;
wwpn: string | null;
wwpn_b: string | null;
target: unknown; // TODO: Probably IscsiTarget
}

export interface FibreChannelPortUpdate {
port: string;
target_id: number;
}

export type FibreChannelPortChoices = Record<string, {
wwpn: string;
wwpn_b: string;
}>;
10 changes: 5 additions & 5 deletions src/app/pages/services/services.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { provideMockStore } from '@ngrx/store/testing';
import { MockComponent } from 'ng-mocks';
import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils';
import { mockAuth } from 'app/core/testing/utils/mock-auth.utils';
import { NavigateAndInteractService } from 'app/directives/navigate-and-interact/navigate-and-interact.service';
import { ServiceName, serviceNames } from 'app/enums/service-name.enum';
import { ServiceStatus } from 'app/enums/service-status.enum';
import { Service } from 'app/interfaces/service.interface';
Expand Down Expand Up @@ -73,6 +74,7 @@ describe('ServicesComponent', () => {
mockProvider(DialogService),
mockProvider(SlideInService),
mockProvider(IscsiService),
mockProvider(NavigateAndInteractService),
provideMockStore({
initialState,
selectors: [{
Expand Down Expand Up @@ -105,15 +107,13 @@ describe('ServicesComponent', () => {
});

describe('edit', () => {
it('should redirect to configure iSCSI service page when edit button is pressed', async () => {
const router = spectator.inject(Router);
jest.spyOn(router, 'navigate').mockResolvedValue(true);

it('should redirect and open form to configure iSCSI service page when edit button is pressed', async () => {
const serviceIndex = fakeDataSource.findIndex((item) => item.service === ServiceName.Iscsi) + 1;
const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'edit' }), serviceIndex, 3);
await editButton.click();

expect(router.navigate).toHaveBeenCalledWith(['/sharing', 'iscsi']);
expect(spectator.inject(NavigateAndInteractService).navigateAndInteract)
.toHaveBeenCalledWith(['/sharing', 'iscsi'], 'global-configuration');
});

it('should open FTP configuration when edit button is pressed', async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/app/pages/services/services.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { EMPTY, of } from 'rxjs';
import {
catchError, map, take,
} from 'rxjs/operators';
import { NavigateAndInteractService } from 'app/directives/navigate-and-interact/navigate-and-interact.service';
import { UiSearchDirective } from 'app/directives/ui-search.directive';
import { AuditService } from 'app/enums/audit.enum';
import { EmptyType } from 'app/enums/empty-type.enum';
Expand Down Expand Up @@ -42,7 +43,6 @@ import {
import { ServiceUpsComponent } from 'app/pages/services/components/service-ups/service-ups.component';
import { servicesElements } from 'app/pages/services/services.elements';
import { ErrorHandlerService } from 'app/services/error-handler.service';
import { IscsiService } from 'app/services/iscsi.service';
import { ServicesService } from 'app/services/services.service';
import { SlideInService } from 'app/services/slide-in.service';
import { UrlOptionsService } from 'app/services/url-options.service';
Expand All @@ -55,7 +55,6 @@ import { waitForServices } from 'app/store/services/services.selectors';
@Component({
selector: 'ix-services',
templateUrl: './services.component.html',
providers: [IscsiService],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
Expand Down Expand Up @@ -147,6 +146,7 @@ export class ServicesComponent implements OnInit {
private urlOptions: UrlOptionsService,
private errorHandler: ErrorHandlerService,
private loader: AppLoaderService,
private navigateAndInteract: NavigateAndInteractService,
) {}

ngOnInit(): void {
Expand Down Expand Up @@ -212,7 +212,7 @@ export class ServicesComponent implements OnInit {
private configureService(row: Service): void {
switch (row.service) {
case ServiceName.Iscsi:
this.router.navigate(['/sharing', 'iscsi']);
this.navigateAndInteract.navigateAndInteract(['/sharing', 'iscsi'], 'global-configuration');
break;
case ServiceName.Ftp:
this.slideInService.open(ServiceFtpComponent, { wide: true });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<mat-card>
<mat-toolbar-row>
<div class="toolbar-row-title">
<a [ixTest]="['iscsi-share', 'open-in-new']" [routerLink]="['/sharing', 'iscsi', 'target']">
<a [ixTest]="['iscsi-share', 'open-in-new']" [routerLink]="['/sharing', 'iscsi', 'targets']">
<h3 class="card-title">
{{ 'Block (iSCSI) Shares Targets' | translate }}
<ix-icon name="open_in_new" class="title-icon"></ix-icon>
Expand All @@ -15,16 +15,6 @@ <h3 class="card-title">
</div>

<div class="actions">
<button
*ixRequiresRoles="requiredRoles"
mat-button
[ixTest]="['iscsi-share', 'configure']"
[routerLink]="['/sharing', 'iscsi', 'configuration']"
[ixUiSearch]="searchableElements.elements.configure"
>
{{ 'Configure' | translate }}
</button>

<button
*ixRequiresRoles="requiredRoles"
mat-button
Expand Down Expand Up @@ -64,6 +54,6 @@ <h3 class="card-title">
[pageSize]="4"
[dataProvider]="dataProvider"
[ixTestOverride]="['iscsi']"
[routerLink]="['/sharing', 'iscsi', 'target']"
[routerLink]="['/sharing', 'iscsi', 'targets']"
></ix-table-pager-show-more>
</mat-card>
Loading

0 comments on commit f448132

Please sign in to comment.