diff --git a/scripts/ui-search/extract-ui-search-elements.ts b/scripts/ui-search/extract-ui-search-elements.ts index 226a766dc75..630ac066993 100644 --- a/scripts/ui-search/extract-ui-search-elements.ts +++ b/scripts/ui-search/extract-ui-search-elements.ts @@ -8,6 +8,10 @@ * 2️⃣. Create .elements.ts config file near the .component.html file ~ [pools-dashboard.elements.ts] * * Example of creating a new searchable element: + * + * !! It's required to add anchor to the element where you do not specify hierarchy explicitly !! + * You will get TS error if it's not provided correctly + * export const customSearchableElements = { hierarchy: [T('System'), T('Advanced Settings'), T('Access')], diff --git a/scripts/ui-search/parse-ui-search-elements.ts b/scripts/ui-search/parse-ui-search-elements.ts index 1ad6bf73c65..399761a6e9f 100644 --- a/scripts/ui-search/parse-ui-search-elements.ts +++ b/scripts/ui-search/parse-ui-search-elements.ts @@ -61,14 +61,16 @@ export function parseUiSearchElements( function createUiSearchElement( cheerioRoot$: (selector: CheerioElement | string) => { attr: (attr: string) => string }, element: CheerioElement, - elementConfig: UiSearchableElement, + elementConfig: Record, parentKey: keyof UiSearchableElement, childKey: keyof UiSearchableElement, componentProperties: Record, ): UiSearchableElement { try { const parent = (elementConfig?.[parentKey] || elementConfig) as UiSearchableElement; - const child = parent?.elements?.[childKey] || parent?.manualRenderElements?.[childKey] || {}; + const child = parent?.elements?.[childKey] + || parent?.manualRenderElements?.[childKey] + || {} as UiSearchableElement; const hierarchy = [...parent?.hierarchy || [], ...child?.hierarchy || []]; const visibleTokens = [...parent?.visibleTokens || [], ...child?.visibleTokens || []]; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b8ddb79253a..1c07d95c335 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -6,10 +6,10 @@ import { Router, NavigationEnd, RouterOutlet } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { filter, tap } from 'rxjs'; import { WINDOW } from 'app/helpers/window.helper'; -import { AuthService } from 'app/modules/auth/auth.service'; import { LayoutService } from 'app/modules/layout/layout.service'; import { PingService } from 'app/modules/websocket/ping.service'; import { DetectBrowserService } from 'app/services/detect-browser.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; @UntilDestroy() @Component({ @@ -24,13 +24,13 @@ export class AppComponent implements OnInit { constructor( public title: Title, private router: Router, - private authService: AuthService, + private wsStatus: WebSocketStatusService, private detectBrowser: DetectBrowserService, private layoutService: LayoutService, private pingService: PingService, @Inject(WINDOW) private window: Window, ) { - this.authService.isAuthenticated$.pipe(untilDestroyed(this)).subscribe((isAuthenticated) => { + this.wsStatus.isAuthenticated$.pipe(untilDestroyed(this)).subscribe((isAuthenticated) => { this.isAuthenticated = isAuthenticated; }); this.title.setTitle('TrueNAS - ' + this.window.location.hostname); diff --git a/src/app/core/testing/classes/mock-api.service.ts b/src/app/core/testing/classes/mock-api.service.ts index 16c31e8ed88..7fe01659149 100644 --- a/src/app/core/testing/classes/mock-api.service.ts +++ b/src/app/core/testing/classes/mock-api.service.ts @@ -22,6 +22,7 @@ import { Job } from 'app/interfaces/job.interface'; import { ApiService } from 'app/modules/websocket/api.service'; import { SubscriptionManagerService } from 'app/modules/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; /** * Better than just expect.anything() because it allows null and undefined. @@ -47,10 +48,11 @@ export class MockApiService extends ApiService { constructor( wsHandler: WebSocketHandlerService, + wsStatus: WebSocketStatusService, subscriptionManager: SubscriptionManagerService, translate: TranslateService, ) { - super(wsHandler, subscriptionManager, translate); + super(wsHandler, wsStatus, subscriptionManager, translate); this.call = jest.fn(); this.job = jest.fn(); diff --git a/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts b/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts index f9ec4dc6de4..b20ba4ae8f2 100644 --- a/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts +++ b/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts @@ -10,6 +10,7 @@ import { SystemInfo } from 'app/interfaces/system-info.interface'; import { ApiService } from 'app/modules/websocket/api.service'; import { SubscriptionManagerService } from 'app/modules/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; @Injectable({ providedIn: 'root', @@ -20,10 +21,11 @@ export class MockEnclosureApiService extends ApiService { constructor( wsManager: WebSocketHandlerService, + wsStatus: WebSocketStatusService, subscriptionManager: SubscriptionManagerService, translate: TranslateService, ) { - super(wsManager, subscriptionManager, translate); + super(wsManager, wsStatus, subscriptionManager, translate); console.warn('MockEnclosureApiService is in effect. Some calls will be mocked'); } diff --git a/src/app/core/testing/utils/empty-auth.service.ts b/src/app/core/testing/utils/empty-auth.service.ts index 62d28659523..042356fb1ee 100644 --- a/src/app/core/testing/utils/empty-auth.service.ts +++ b/src/app/core/testing/utils/empty-auth.service.ts @@ -3,7 +3,6 @@ import { AuthService } from 'app/modules/auth/auth.service'; export class EmptyAuthService { readonly authToken$ = getMissingInjectionErrorObservable(AuthService.name); - readonly isAuthenticated$ = getMissingInjectionErrorObservable(AuthService.name); readonly user$ = getMissingInjectionErrorObservable(AuthService.name); readonly isSysAdmin$ = getMissingInjectionErrorObservable(AuthService.name); readonly userTwoFactorConfig$ = getMissingInjectionErrorObservable(AuthService.name); diff --git a/src/app/core/testing/utils/mock-api.utils.ts b/src/app/core/testing/utils/mock-api.utils.ts index ab3fc3b7644..a617bc365b6 100644 --- a/src/app/core/testing/utils/mock-api.utils.ts +++ b/src/app/core/testing/utils/mock-api.utils.ts @@ -14,6 +14,7 @@ import { Job } from 'app/interfaces/job.interface'; import { ApiService } from 'app/modules/websocket/api.service'; import { SubscriptionManagerService } from 'app/modules/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; /** * This is a sugar syntax for creating simple api mocks. @@ -49,11 +50,12 @@ export function mockApi( { provide: ApiService, useFactory: ( + wsStatus: WebSocketStatusService, wsHandler: WebSocketHandlerService, translate: TranslateService, ) => { const subscriptionManager = {} as SubscriptionManagerService; - const mockApiService = new MockApiService(wsHandler, subscriptionManager, translate); + const mockApiService = new MockApiService(wsHandler, wsStatus, subscriptionManager, translate); (mockResponses || []).forEach((mockResponse) => { if (mockResponse.type === MockApiResponseType.Call) { mockApiService.mockCall(mockResponse.method, mockResponse.response); @@ -66,7 +68,11 @@ export function mockApi( }); return mockApiService; }, - deps: [WebSocketHandlerService, TranslateService], + deps: [WebSocketStatusService, WebSocketHandlerService, TranslateService], + }, + { + provide: WebSocketStatusService, + useValue: ({} as WebSocketStatusService), }, { provide: MockApiService, diff --git a/src/app/core/testing/utils/mock-auth.utils.ts b/src/app/core/testing/utils/mock-auth.utils.ts index 98778a5df6d..b986099cef7 100644 --- a/src/app/core/testing/utils/mock-auth.utils.ts +++ b/src/app/core/testing/utils/mock-auth.utils.ts @@ -11,8 +11,8 @@ import { Role } from 'app/enums/role.enum'; import { LoggedInUser } from 'app/interfaces/ds-cache.interface'; import { AuthService } from 'app/modules/auth/auth.service'; import { ApiService } from 'app/modules/websocket/api.service'; -import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; import { TokenLastUsedService } from 'app/services/token-last-used.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; export const dummyUser = { privilege: { @@ -40,13 +40,14 @@ export function mockAuth( provide: AuthService, useFactory: () => { const mockService = new MockAuthService( - createSpyObject(WebSocketHandlerService, { - isConnected$: of(true), - }), createSpyObject(Store), createSpyObject(ApiService), createSpyObject(TokenLastUsedService), createSpyObject(Window), + createSpyObject(WebSocketStatusService, { + isConnected$: of(true), + isAuthenticated$: of(false), + }), ); mockService.setUser(user as LoggedInUser); diff --git a/src/app/enums/virtualization.enum.ts b/src/app/enums/virtualization.enum.ts index c3da88faf47..d1fefac781b 100644 --- a/src/app/enums/virtualization.enum.ts +++ b/src/app/enums/virtualization.enum.ts @@ -1,4 +1,5 @@ import { marker as T } from '@biesbjerg/ngx-translate-extract-marker'; +import { iconMarker } from 'app/modules/ix-icon/icon-marker.util'; export enum VirtualizationType { Container = 'CONTAINER', @@ -10,6 +11,21 @@ export const virtualizationTypeLabels = new Map([ [VirtualizationType.Vm, T('VM')], ]); +export const virtualizationTypeIcons = [ + { + value: VirtualizationType.Container, + icon: iconMarker('mdi-linux'), + label: T('Container'), + description: T('Linux Only'), + }, + { + value: VirtualizationType.Vm, + icon: iconMarker('mdi-laptop'), + label: T('VM'), + description: T('Any OS'), + }, +]; + export enum VirtualizationStatus { Running = 'RUNNING', Stopped = 'STOPPED', @@ -82,3 +98,7 @@ export const virtualizationNicTypeLabels = new Map]; response: ReplicationTask }; // Reporting - 'reporting.exporters.create': { params: [CreateReportingExporter]; response: ReportingExporter }; + 'reporting.exporters.create': { params: [UpdateReportingExporter]; response: ReportingExporter }; 'reporting.exporters.delete': { params: [id: number]; response: boolean }; 'reporting.exporters.exporter_schemas': { params: void; response: ReportingExporterSchema[] }; 'reporting.exporters.query': { params: QueryParams; response: ReportingExporter[] }; @@ -864,7 +864,7 @@ export interface ApiCallDirectory { 'virt.device.disk_choices': { params: []; response: Choices }; 'virt.device.gpu_choices': { - params: [instanceType: VirtualizationType, gpuType: VirtualizationGpuType]; + params: [gpuType: VirtualizationGpuType]; response: AvailableGpus; }; 'virt.device.usb_choices': { params: []; response: Record }; diff --git a/src/app/interfaces/dialog.interface.ts b/src/app/interfaces/dialog.interface.ts index 373f827a70e..4a6df028a1f 100644 --- a/src/app/interfaces/dialog.interface.ts +++ b/src/app/interfaces/dialog.interface.ts @@ -7,7 +7,7 @@ export interface ConfirmOptions { cancelText?: string; disableClose?: boolean; confirmationCheckboxText?: string; - buttonColor?: 'primary' | 'red'; + buttonColor?: 'primary' | 'warn'; } export interface ConfirmOptionsWithSecondaryCheckbox extends ConfirmOptions { diff --git a/src/app/interfaces/enclosure.interface.ts b/src/app/interfaces/enclosure.interface.ts index 166f4df89bd..8f56834e730 100644 --- a/src/app/interfaces/enclosure.interface.ts +++ b/src/app/interfaces/enclosure.interface.ts @@ -80,17 +80,17 @@ export interface DashboardEnclosureSlot { descriptor: string; status: EnclosureStatus; dev: string | null; - supports_identify_light?: boolean; + supports_identify_light: boolean; drive_bay_light_status: DriveBayLightStatus | null; - size?: number | null; - model?: string | null; + size: number | null; + model: string | null; is_top: boolean; is_front: boolean; is_rear: boolean; is_internal: boolean; - serial?: string | null; - type?: DiskType | null; - rotationrate?: number | null; + serial: string | null; + type: DiskType | null; + rotationrate: number | null; pool_info: EnclosureSlotPoolInfo | null; } diff --git a/src/app/interfaces/reporting-exporters.interface.ts b/src/app/interfaces/reporting-exporters.interface.ts index 2d0acff06ef..504917c6684 100644 --- a/src/app/interfaces/reporting-exporters.interface.ts +++ b/src/app/interfaces/reporting-exporters.interface.ts @@ -17,10 +17,8 @@ export interface ReportingExporterList { export interface ReportingExporter { name: string; id: number; - type: string; enabled: boolean; attributes: Record; } -export type CreateReportingExporter = Omit; -export type UpdateReportingExporter = Omit; +export type UpdateReportingExporter = Partial>; diff --git a/src/app/interfaces/schema.interface.ts b/src/app/interfaces/schema.interface.ts index 842ffe17c46..19a4fca0274 100644 --- a/src/app/interfaces/schema.interface.ts +++ b/src/app/interfaces/schema.interface.ts @@ -12,7 +12,7 @@ export interface OldSchema { type: SchemaType | SchemaType[]; _name_: string; _required_: boolean; - + const?: string; } export interface SchemaProperties { diff --git a/src/app/interfaces/virtualization.interface.ts b/src/app/interfaces/virtualization.interface.ts index 7e69c4c04c8..99fd10a481c 100644 --- a/src/app/interfaces/virtualization.interface.ts +++ b/src/app/interfaces/virtualization.interface.ts @@ -8,6 +8,7 @@ import { VirtualizationNicType, VirtualizationProxyProtocol, VirtualizationRemote, + VirtualizationSource, VirtualizationStatus, VirtualizationType, } from 'app/enums/virtualization.enum'; @@ -49,11 +50,20 @@ export interface CreateVirtualizationInstance { image: string; remote: VirtualizationRemote; instance_type: VirtualizationType; + source_type?: VirtualizationSource; environment?: Record; autostart?: boolean; cpu: string; + /** + * Value must be greater or equal to 33554432 + */ memory: number; devices: VirtualizationDevice[]; + enable_vnc?: boolean; + /** + * Value must be greater or equal to 5900 and lesser or equal to 65535 + */ + vnc_port?: number | null; } export interface UpdateVirtualizationInstance { @@ -148,6 +158,7 @@ export interface VirtualizationImage { os: string; release: string; variant: string; + instance_types: VirtualizationType[]; } export interface VirtualizationStopParams { diff --git a/src/app/modules/auth/auth-guard.service.spec.ts b/src/app/modules/auth/auth-guard.service.spec.ts index 72d67f0ec21..2c67e9eb4b1 100644 --- a/src/app/modules/auth/auth-guard.service.spec.ts +++ b/src/app/modules/auth/auth-guard.service.spec.ts @@ -6,7 +6,7 @@ import { } from '@ngneat/spectator/jest'; import { BehaviorSubject } from 'rxjs'; import { AuthGuardService } from 'app/modules/auth/auth-guard.service'; -import { AuthService } from 'app/modules/auth/auth.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; describe('AuthGuardService', () => { const redirectUrl = 'storage/disks'; @@ -17,7 +17,7 @@ describe('AuthGuardService', () => { let spectator: SpectatorService; const createService = createServiceFactory({ service: AuthGuardService, - providers: [mockProvider(AuthService, { isAuthenticated$ })], + providers: [mockProvider(WebSocketStatusService, { isAuthenticated$ })], }); beforeEach(() => { diff --git a/src/app/modules/auth/auth-guard.service.ts b/src/app/modules/auth/auth-guard.service.ts index 48b98da2782..aa29ba14d1d 100644 --- a/src/app/modules/auth/auth-guard.service.ts +++ b/src/app/modules/auth/auth-guard.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { WINDOW } from 'app/helpers/window.helper'; -import { AuthService } from 'app/modules/auth/auth.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; @UntilDestroy() @Injectable({ @@ -12,10 +12,10 @@ export class AuthGuardService { isAuthenticated = false; constructor( private router: Router, - private authService: AuthService, + private wsStatus: WebSocketStatusService, @Inject(WINDOW) private window: Window, ) { - this.authService.isAuthenticated$.pipe(untilDestroyed(this)).subscribe((isLoggedIn) => { + this.wsStatus.isAuthenticated$.pipe(untilDestroyed(this)).subscribe((isLoggedIn) => { this.isAuthenticated = isLoggedIn; }); } diff --git a/src/app/modules/auth/auth.service.spec.ts b/src/app/modules/auth/auth.service.spec.ts index 6a0bc54de51..a51e7c1364c 100644 --- a/src/app/modules/auth/auth.service.spec.ts +++ b/src/app/modules/auth/auth.service.spec.ts @@ -7,7 +7,9 @@ import { StorageStrategyStub, withLocalStorage, } from 'ngx-webstorage'; import * as rxjs from 'rxjs'; -import { firstValueFrom, of } from 'rxjs'; +import { + BehaviorSubject, firstValueFrom, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { MockApiService } from 'app/core/testing/classes/mock-api.service'; import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; @@ -21,7 +23,7 @@ import { LoggedInUser } from 'app/interfaces/ds-cache.interface'; import { Preferences } from 'app/interfaces/preferences.interface'; import { AuthService } from 'app/modules/auth/auth.service'; import { ApiService } from 'app/modules/websocket/api.service'; -import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; const authMeUser = { pw_dir: 'dir', @@ -40,9 +42,12 @@ const authMeUser = { }, } as LoggedInUser; +const mockWsStatus = new WebSocketStatusService(); + describe('AuthService', () => { let spectator: SpectatorService; let testScheduler: TestScheduler; + let timer$: BehaviorSubject<0>; const createService = createServiceFactory({ service: AuthService, providers: [ @@ -59,9 +64,10 @@ describe('AuthService', () => { }, } as LoginExResponse), ]), - mockProvider(WebSocketHandlerService, { - isConnected$: of(true), - }), + { + provide: WebSocketStatusService, + useValue: mockWsStatus, + }, { provide: STORAGE_STRATEGIES, useFactory: () => new StorageStrategyStub(LocalStorageStrategy.strategyName), @@ -78,11 +84,14 @@ describe('AuthService', () => { testScheduler = new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); + mockWsStatus.setConnectionStatus(true); + timer$ = new BehaviorSubject(0); + jest.spyOn(rxjs, 'timer').mockReturnValue(timer$.asObservable()); }); describe('Login', () => { it('initializes auth session with triggers and token with username/password login', () => { - jest.spyOn(rxjs, 'timer').mockReturnValueOnce(of(0)); + timer$.next(0); const obs$ = spectator.service.login('dummy', 'dummy'); @@ -91,10 +100,6 @@ describe('AuthService', () => { '(a|)', { a: LoginResult.Success }, ); - expectObservable(spectator.service.isAuthenticated$).toBe( - 'c', - { c: true }, - ); expectObservable(spectator.service.authToken$).toBe( 'd', { d: 'DUMMY_TOKEN' }, @@ -109,7 +114,7 @@ describe('AuthService', () => { }); it('initializes auth session with triggers and token with token login', () => { - jest.spyOn(rxjs, 'timer').mockReturnValueOnce(of(0)); + timer$.next(0); const obs$ = spectator.service.loginWithToken(); @@ -118,10 +123,6 @@ describe('AuthService', () => { '(a|)', { a: LoginResult.Success }, ); - expectObservable(spectator.service.isAuthenticated$).toBe( - 'c', - { c: true }, - ); expectObservable(spectator.service.authToken$).toBe( 'd', { d: 'DUMMY_TOKEN' }, @@ -146,10 +147,6 @@ describe('AuthService', () => { a: undefined, }, ); - expectObservable(spectator.service.isAuthenticated$).toBe( - 'c', - { c: false }, - ); expectObservable(spectator.service.authToken$).toBe( '|', {}, diff --git a/src/app/modules/auth/auth.service.ts b/src/app/modules/auth/auth.service.ts index 33b6d6834a3..71c816b10bd 100644 --- a/src/app/modules/auth/auth.service.ts +++ b/src/app/modules/auth/auth.service.ts @@ -3,7 +3,6 @@ import { Store } from '@ngrx/store'; import { LocalStorage } from 'ngx-webstorage'; import { BehaviorSubject, - combineLatest, filter, map, Observable, @@ -23,8 +22,8 @@ import { LoginExMechanism, LoginExResponse, LoginExResponseType } from 'app/inte import { LoggedInUser } from 'app/interfaces/ds-cache.interface'; import { GlobalTwoFactorConfig } from 'app/interfaces/two-factor-config.interface'; import { ApiService } from 'app/modules/websocket/api.service'; -import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; import { TokenLastUsedService } from 'app/services/token-last-used.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; import { AppState } from 'app/store'; import { adminUiInitialized } from 'app/store/admin-panel/admin.actions'; @@ -51,19 +50,8 @@ export class AuthService { return Boolean(this.token) && this.token !== 'null'; } - private isLoggedIn$ = new BehaviorSubject(false); - private generateTokenSubscription: Subscription | null; - readonly isAuthenticated$ = combineLatest([ - this.wsManager.isConnected$, - this.isLoggedIn$.asObservable(), - ]).pipe( - switchMap(([isConnected, isLoggedIn]) => { - return of(isConnected && isLoggedIn); - }), - ); - readonly user$ = this.loggedInUser$.asObservable(); /** @@ -82,11 +70,11 @@ export class AuthService { private cachedGlobalTwoFactorConfig: GlobalTwoFactorConfig | null; constructor( - private wsManager: WebSocketHandlerService, private store$: Store, private api: ApiService, private tokenLastUsedService: TokenLastUsedService, @Inject(WINDOW) private window: Window, + private wsStatus: WebSocketStatusService, ) { this.setupAuthenticationUpdate(); this.setupWsConnectionUpdate(); @@ -183,7 +171,7 @@ export class AuthService { tap(() => { this.clearAuthToken(); this.api.clearSubscriptions(); - this.isLoggedIn$.next(false); + this.wsStatus.setLoginStatus(false); }), ); } @@ -202,18 +190,18 @@ export class AuthService { this.loggedInUser$.next(result.user_info); if (!result.user_info?.privilege?.webui_access) { - this.isLoggedIn$.next(false); + this.wsStatus.setLoginStatus(false); return of(LoginResult.NoAccess); } - this.isLoggedIn$.next(true); + this.wsStatus.setLoginStatus(true); this.window.sessionStorage.setItem('loginBannerDismissed', 'true'); return this.authToken$.pipe( take(1), map(() => LoginResult.Success), ); } - this.isLoggedIn$.next(false); + this.wsStatus.setLoginStatus(false); if (result.response_type === LoginExResponseType.OtpRequired) { return of(LoginResult.NoOtp); @@ -226,8 +214,8 @@ export class AuthService { private setupPeriodicTokenGeneration(): void { if (!this.generateTokenSubscription || this.generateTokenSubscription.closed) { this.generateTokenSubscription = timer(0, this.tokenRegenerationTimeMillis).pipe( - switchMap(() => this.isAuthenticated$.pipe(take(1))), - filter((isAuthenticated) => isAuthenticated), + switchMap(() => this.wsStatus.isAuthenticated$.pipe(take(1))), + filter(Boolean), switchMap(() => this.api.call('auth.generate_token')), tap((token) => this.latestTokenGenerated$.next(token)), ).subscribe(); @@ -243,7 +231,7 @@ export class AuthService { } private setupAuthenticationUpdate(): void { - this.isAuthenticated$.subscribe({ + this.wsStatus.isAuthenticated$.subscribe({ next: (isAuthenticated) => { if (isAuthenticated) { this.store$.dispatch(adminUiInitialized()); @@ -260,8 +248,8 @@ export class AuthService { } private setupWsConnectionUpdate(): void { - this.wsManager.isConnected$.pipe(filter((isConnected) => !isConnected)).subscribe(() => { - this.isLoggedIn$.next(false); + this.wsStatus.isConnected$.pipe(filter((isConnected) => !isConnected)).subscribe(() => { + this.wsStatus.setLoginStatus(false); }); } diff --git a/src/app/modules/auth/two-factor-guard.service.spec.ts b/src/app/modules/auth/two-factor-guard.service.spec.ts index 721b59de6fb..55f0c3fa6c5 100644 --- a/src/app/modules/auth/two-factor-guard.service.spec.ts +++ b/src/app/modules/auth/two-factor-guard.service.spec.ts @@ -5,21 +5,24 @@ import { GlobalTwoFactorConfig, UserTwoFactorConfig } from 'app/interfaces/two-f import { AuthService } from 'app/modules/auth/auth.service'; import { TwoFactorGuardService } from 'app/modules/auth/two-factor-guard.service'; import { DialogService } from 'app/modules/dialog/dialog.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; describe('TwoFactorGuardService', () => { let spectator: SpectatorService; const isAuthenticated$ = new BehaviorSubject(false); - const userTwoFactorConfig$ = new BehaviorSubject(null as UserTwoFactorConfig); - const getGlobalTwoFactorConfig = jest.fn(() => of(null as GlobalTwoFactorConfig)); + const userTwoFactorConfig$ = new BehaviorSubject(null); + const getGlobalTwoFactorConfig = jest.fn(() => of(null as GlobalTwoFactorConfig | null)); const hasRole$ = new BehaviorSubject(false); const createService = createServiceFactory({ service: TwoFactorGuardService, providers: [ mockProvider(Router), - mockProvider(AuthService, { + mockProvider(WebSocketStatusService, { isAuthenticated$, + }), + mockProvider(AuthService, { userTwoFactorConfig$, getGlobalTwoFactorConfig, hasRole: jest.fn(() => hasRole$), diff --git a/src/app/modules/auth/two-factor-guard.service.ts b/src/app/modules/auth/two-factor-guard.service.ts index 58ae5ce005f..96b961fad3f 100644 --- a/src/app/modules/auth/two-factor-guard.service.ts +++ b/src/app/modules/auth/two-factor-guard.service.ts @@ -10,6 +10,7 @@ import { import { Role } from 'app/enums/role.enum'; import { AuthService } from 'app/modules/auth/auth.service'; import { DialogService } from 'app/modules/dialog/dialog.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; @UntilDestroy() @Injectable({ @@ -19,12 +20,13 @@ export class TwoFactorGuardService implements CanActivateChild { constructor( private router: Router, private authService: AuthService, + private wsStatus: WebSocketStatusService, private dialogService: DialogService, private translate: TranslateService, ) { } canActivateChild(_: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.authService.isAuthenticated$.pipe( + return this.wsStatus.isAuthenticated$.pipe( take(1), switchMap((isAuthenticated) => { if (!isAuthenticated) { diff --git a/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts b/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts index 91ef4c355c2..dd38eac4a0c 100644 --- a/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts +++ b/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts @@ -34,7 +34,7 @@ export class ErrorTemplateComponent { private readonly errorBtPanel: Signal | undefined> = viewChild('errorBtPanel', { read: ElementRef }); private readonly errorBtText: Signal | undefined> = viewChild('errorBtText', { read: ElementRef }); - readonly title = input(); + readonly title = input.required(); readonly message = input(); readonly backtrace = input(); readonly logs = input(); diff --git a/src/app/modules/dialog/components/session-expiring-dialog/session-expiring-dialog.component.ts b/src/app/modules/dialog/components/session-expiring-dialog/session-expiring-dialog.component.ts index ba5e980d2df..fe8d3268462 100644 --- a/src/app/modules/dialog/components/session-expiring-dialog/session-expiring-dialog.component.ts +++ b/src/app/modules/dialog/components/session-expiring-dialog/session-expiring-dialog.component.ts @@ -8,9 +8,14 @@ import { } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; import { NavigateAndInteractDirective } from 'app/directives/navigate-and-interact/navigate-and-interact.directive'; -import { ConfirmOptionsWithSecondaryCheckbox } from 'app/interfaces/dialog.interface'; import { TestDirective } from 'app/modules/test-id/test.directive'; +export interface SessionExpiringDialogOptions { + title: string; + message: string; + buttonText: string; +} + @Component({ selector: 'ix-session-expiring-dialog', templateUrl: './session-expiring-dialog.component.html', @@ -28,11 +33,11 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; ], }) export class SessionExpiringDialogComponent { - options: ConfirmOptionsWithSecondaryCheckbox; + options: SessionExpiringDialogOptions; constructor( private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) options: ConfirmOptionsWithSecondaryCheckbox, + @Inject(MAT_DIALOG_DATA) options: SessionExpiringDialogOptions, ) { this.options = { ...options }; } diff --git a/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.html b/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.html index 394f5f60c3f..5da7351b112 100644 --- a/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.html +++ b/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.html @@ -1,6 +1,6 @@

{{ 'Logs' | translate }}

-@if (job?.logs_excerpt) { +@if (job.logs_excerpt) {
{{ job.logs_excerpt }}
diff --git a/src/app/modules/dialog/components/start-service-dialog/start-service-dialog.component.ts b/src/app/modules/dialog/components/start-service-dialog/start-service-dialog.component.ts index fddf94217f8..5a728bad2c9 100644 --- a/src/app/modules/dialog/components/start-service-dialog/start-service-dialog.component.ts +++ b/src/app/modules/dialog/components/start-service-dialog/start-service-dialog.component.ts @@ -9,7 +9,7 @@ import { import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; -import { Observable, forkJoin } from 'rxjs'; +import { Observable, forkJoin, filter } from 'rxjs'; import { ServiceName, serviceNames } from 'app/enums/service-name.enum'; import { Service } from 'app/interfaces/service.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; @@ -131,7 +131,10 @@ export class StartServiceDialogComponent implements OnInit { private getService(): void { this.store$.select(selectService(this.serviceName)) - .pipe(untilDestroyed(this)) + .pipe( + filter((service) => !!service), + untilDestroyed(this), + ) .subscribe((service) => { this.service = service; this.cdr.markForCheck(); diff --git a/src/app/modules/empty/empty.component.html b/src/app/modules/empty/empty.component.html index c480383d35a..15708f386b8 100644 --- a/src/app/modules/empty/empty.component.html +++ b/src/app/modules/empty/empty.component.html @@ -1,7 +1,7 @@
@if (!isLoading()) {
diff --git a/src/app/modules/empty/empty.component.ts b/src/app/modules/empty/empty.component.ts index b987c388ae3..5f7a45a2676 100644 --- a/src/app/modules/empty/empty.component.ts +++ b/src/app/modules/empty/empty.component.ts @@ -31,7 +31,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; }) export class EmptyComponent { readonly conf = input.required(); - readonly requiredRoles = input(); + readonly requiredRoles = input([]); doAction(): void { const action = this.conf().button?.action; diff --git a/src/app/modules/empty/empty.service.ts b/src/app/modules/empty/empty.service.ts index c8f43339567..e3154eca5de 100644 --- a/src/app/modules/empty/empty.service.ts +++ b/src/app/modules/empty/empty.service.ts @@ -9,7 +9,7 @@ import { EmptyConfig } from 'app/interfaces/empty-config.interface'; export class EmptyService { constructor(private translate: TranslateService) { } - defaultEmptyConfig(type: EmptyType): EmptyConfig { + defaultEmptyConfig(type?: EmptyType | null): EmptyConfig { switch (type) { case EmptyType.Loading: return { diff --git a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html index a858d272ac9..010f1be2861 100644 --- a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html @@ -3,7 +3,7 @@ [label]="label()" [tooltip]="tooltip()" [required]="required()" - [ixTestOverride]="controlDirective.name" + [ixTestOverride]="controlDirective.name || ''" > } diff --git a/src/app/modules/forms/ix-forms/components/ix-code-editor/ix-code-editor.component.ts b/src/app/modules/forms/ix-forms/components/ix-code-editor/ix-code-editor.component.ts index e2a0777a20c..55904c7f9c9 100644 --- a/src/app/modules/forms/ix-forms/components/ix-code-editor/ix-code-editor.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-code-editor/ix-code-editor.component.ts @@ -12,7 +12,7 @@ import { import { ControlValueAccessor, NgControl, ReactiveFormsModule } from '@angular/forms'; import { MatHint } from '@angular/material/form-field'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; -import { Compartment } from '@codemirror/state'; +import { Compartment, Extension } from '@codemirror/state'; import { EditorView, EditorViewConfig, keymap, lineNumbers, placeholder, } from '@codemirror/view'; @@ -142,19 +142,25 @@ export class IxCodeEditorComponent implements OnChanges, OnInit, AfterViewInit, this.onChange(update.state.doc.toString()); }); + const extensions: Extension[] = [ + basicSetup, + updateListener, + lineNumbers(), + history(), + keymap.of([...defaultKeymap as unknown[], ...historyKeymap]), + material, + this.editableCompartment.of(EditorView.editable.of(true)), + placeholder(this.placeholder()), + ]; + + const language = this.language(); + if (language) { + extensions.push(languageFunctionsMap[language]()); + } + const config: EditorViewConfig = { + extensions, doc: this.controlDirective.control?.value as string || '', - extensions: [ - basicSetup, - updateListener, - languageFunctionsMap[this.language()](), - lineNumbers(), - history(), - keymap.of([...defaultKeymap as unknown[], ...historyKeymap]), - material, - this.editableCompartment.of(EditorView.editable.of(true)), - placeholder(this.placeholder()), - ], parent: this.inputArea().nativeElement, }; this.editorView = new EditorView(config); diff --git a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html index aa572d67ed7..80187af42ca 100644 --- a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html @@ -4,7 +4,7 @@ [label]="label()" [tooltip]="tooltip()" [required]="required()" - [ixTestOverride]="controlDirective.name" + [ixTestOverride]="controlDirective.name || ''" > } diff --git a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts index 7178c43a547..5fd868936ae 100644 --- a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts @@ -74,7 +74,7 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit { }); private readonly inputElementRef: Signal> = viewChild.required('ixInput', { read: ElementRef }); - private readonly autoCompleteRef = viewChild('auto', { read: MatAutocomplete }); + private readonly autoCompleteRef = viewChild.required('auto', { read: MatAutocomplete }); private readonly autocompleteTrigger = viewChild(MatAutocompleteTrigger); options: Option[] = []; @@ -84,9 +84,9 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit { private filterChanged$ = new Subject(); - value: string | number = ''; + value: string | number | null = ''; isDisabled = false; - filterValue: string; + filterValue: string | null; selectedOption: Option | null = null; textContent = ''; diff --git a/src/app/modules/forms/ix-forms/components/ix-form-glossary/ix-form-glossary.component.ts b/src/app/modules/forms/ix-forms/components/ix-form-glossary/ix-form-glossary.component.ts index 67bbc03612a..43163eb3fa7 100644 --- a/src/app/modules/forms/ix-forms/components/ix-form-glossary/ix-form-glossary.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-form-glossary/ix-form-glossary.component.ts @@ -147,7 +147,7 @@ export class IxFormGlossaryComponent implements OnInit { } protected isSectionValid(section: IxFormSectionComponent): boolean { - return this.sectionsValidity.get(section); + return !!this.sectionsValidity.get(section); } ngOnInit(): void { diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts b/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts index a2e2a393977..dc46b73c35c 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts @@ -4,4 +4,5 @@ export interface IconGroupOption { icon: MarkedIcon; label: string; value: string; + description?: string; } diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html index 4f29383bc27..e11a0e2b7ce 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html @@ -6,23 +6,37 @@ > } -
+
@for (option of options(); track option) { - +
+ + + @if (showLabels()) { +
{{ option.label | translate }}
+ @if (option.description) { + {{ option.description | translate }} + } + } +
} @empty { {{ 'No options are passed' | translate }} } diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss index 6b5d0bdd38c..05fb0a91e58 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss @@ -20,3 +20,51 @@ .selected { color: var(--primary); } + +.title, +.description { + display: block; + margin: 0; + text-align: center; +} + +.title { + font-size: 14px; + margin-bottom: 2px; + margin-top: 8px; +} + +.description { + color: var(--fg2); +} + +.with-labels { + gap: 16px; + + ::ng-deep .mdc-icon-button { + border: 2px solid var(--lines); + border-radius: 0; + height: 100px !important; + line-height: 100px; + width: 100px !important; + + .mdc-icon-button__ripple, + .mat-ripple { + border-radius: 0 !important; + height: 100px !important; + width: 100px !important; + } + + .ix-icon, + .ix-icon svg { + font-size: 40px; + height: 40px; + line-height: 1; + width: 40px; + } + + &.selected { + border-color: var(--primary); + } + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts index bd02b7b5265..6320dd8658a 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts @@ -28,6 +28,7 @@ describe('IxIconGroupComponent', () => { [tooltip]="tooltip" [required]="required" [formControl]="formControl" + [showLabels]="true" >`, { hostProps: { @@ -85,6 +86,14 @@ describe('IxIconGroupComponent', () => { formControl.setValue('edit'); expect(await iconGroupHarness.getValue()).toBe('edit'); }); + it('shows labels when `showLabels` is set to true', async () => { + const icons = await iconGroupHarness.getIcons(); + expect(icons).toHaveLength(2); + + const labels = spectator.queryAll('h5.title').map((el) => el.textContent); + expect(labels[0]).toBe('Edit'); + expect(labels[1]).toBe('Delete'); + }); }); it('updates form control value when user presses the button', async () => { diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts index 0a342a0dece..b2cee9d93ca 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts @@ -20,13 +20,13 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ - IxLabelComponent, - MatIconButton, - IxIconComponent, IxErrorsComponent, + IxIconComponent, + IxLabelComponent, ReactiveFormsModule, - TranslateModule, TestDirective, + TranslateModule, + MatIconButton, ], hostDirectives: [ { ...registeredDirectiveConfig }, @@ -37,6 +37,7 @@ export class IxIconGroupComponent implements ControlValueAccessor { readonly label = input(); readonly tooltip = input(); readonly required = input(false); + readonly showLabels = input(false); protected isDisabled = false; protected value: IconGroupOption['value']; diff --git a/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts b/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts index 935601737b1..1c9adee1e70 100644 --- a/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts @@ -67,7 +67,7 @@ export class IxInputComponent implements ControlValueAccessor, OnInit, OnChanges readonly tooltip = input(); readonly required = input(false); readonly readonly = input(); - readonly type = input(); + readonly type = input('text'); readonly autocomplete = input('off'); readonly autocompleteOptions = input(); readonly maxLength = input(524288); @@ -75,9 +75,9 @@ export class IxInputComponent implements ControlValueAccessor, OnInit, OnChanges /** If formatted value returned by parseAndFormatInput has non-numeric letters * and input 'type' is a number, the input will stay empty on the form */ readonly format = input<(value: string | number) => string>(); - readonly parse = input<(value: string | number) => string | number>(); + readonly parse = input<(value: string | number) => string | number | null>(); - readonly inputElementRef: Signal> = viewChild('ixInput', { read: ElementRef }); + readonly inputElementRef: Signal> = viewChild.required('ixInput', { read: ElementRef }); private _value: string | number = this.controlDirective.value as string; formatted: string | number = ''; @@ -212,12 +212,15 @@ export class IxInputComponent implements ControlValueAccessor, OnInit, OnChanges this.onTouch(); if (this.formatted) { - if (this.parse()) { - this.value = this.parse()(this.formatted); + const parse = this.parse(); + if (parse) { + this.value = parse(this.formatted); this.formatted = this.value; } - if (this.format()) { - this.formatted = this.format()(this.value); + + const format = this.format(); + if (format) { + this.formatted = format(this.value); } } diff --git a/src/app/modules/forms/ix-forms/components/warning/warning.component.ts b/src/app/modules/forms/ix-forms/components/warning/warning.component.ts index 4154094059d..416b0a27587 100644 --- a/src/app/modules/forms/ix-forms/components/warning/warning.component.ts +++ b/src/app/modules/forms/ix-forms/components/warning/warning.component.ts @@ -15,6 +15,6 @@ import { TranslateModule } from '@ngx-translate/core'; imports: [NgClass, TranslateModule], }) export class WarningComponent { - readonly message = input(); + readonly message = input.required(); readonly color = input<'green' | 'orange'>('orange'); } diff --git a/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts b/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts index ddde9f6ce8f..cd593d49a5e 100644 --- a/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts +++ b/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts @@ -72,6 +72,10 @@ export class FormErrorHandlerService { const extra = (error as ApiError).extra as string[][]; for (const extraItem of extra) { const field = extraItem[0].split('.').pop(); + if (!field) { + return; + } + const errorMessage = extraItem[1]; const control = this.getFormField(formGroup, field, fieldsMap); diff --git a/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts b/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts index f1cf85bdf7e..bd78c0e4e4d 100644 --- a/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts +++ b/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts @@ -1,78 +1,90 @@ -import { NgControl } from '@angular/forms'; +import { ElementRef } from '@angular/core'; +import { FormControl, NgControl } from '@angular/forms'; import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'app/core/testing/utils/get-test-scheduler.utils'; +import { IxFormSectionComponent } from 'app/modules/forms/ix-forms/components/ix-form-section/ix-form-section.component'; +import { ixControlLabelTag } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; import { IxFormService } from 'app/modules/forms/ix-forms/services/ix-form.service'; -// TODO: https://ixsystems.atlassian.net/browse/NAS-133118 -describe.skip('IxFormService', () => { +class MockNgControl extends NgControl { + override control = new FormControl('mock-value'); + + override viewToModelUpdate(newValue: string): void { + this.control.setValue(newValue); + } +} + +describe('IxFormService', () => { let spectator: SpectatorService; + let testScheduler: TestScheduler; const createService = createServiceFactory({ service: IxFormService, }); - const fakeComponents = [ - { - control: { - name: 'test_control_1', - }, - element: { - nativeElement: { - id: 'test_element_1', - }, - getAttribute: () => 'Test Element 1', - }, - }, - { - control: { - name: 'test_control_2', - }, - element: { - nativeElement: { - id: 'test_element_2', - }, - getAttribute: () => 'Test Element 2', - }, - }, - ] as { - control: NgControl; - element: { nativeElement: HTMLElement; getAttribute: () => string }; - }[]; - beforeEach(() => { spectator = createService(); - fakeComponents.forEach((component) => { - spectator.service.registerControl(component.control.name!.toString(), component.element); - }); + testScheduler = getTestScheduler(); }); - describe('getControlsNames', () => { - it('returns a list of control names', () => { - expect(spectator.service.getControlNames()).toEqual([ - 'test_control_1', - 'test_control_2', - ]); + describe('handles control register/unregister', () => { + it('registers control', () => { + const elRef = new ElementRef(document.createElement('input')); + elRef.nativeElement.setAttribute('id', 'control1'); + elRef.nativeElement.setAttribute(ixControlLabelTag, 'Control1'); + spectator.service.registerControl( + 'control1', + elRef, + ); + + expect(spectator.service.getControlNames()).toEqual(['control1']); + testScheduler.run(({ expectObservable }) => { + expectObservable(spectator.service.controlNamesWithLabels$).toBe('a', { + a: [{ label: 'Control1', name: 'control1' }], + }); + }); + expect(spectator.service.getElementByControlName('control1')).toEqual(elRef.nativeElement); + expect(spectator.service.getElementByLabel('Control1')).toEqual(elRef.nativeElement); }); - }); - describe('getControls', () => { - it('returns a list of controls', () => { - expect(spectator.service.getControlNames()).toEqual([ - 'test_control_1', - 'test_control_2', - ]); + it('unregisters control', () => { + const elRef = new ElementRef(document.createElement('input')); + elRef.nativeElement.setAttribute('id', 'control1'); + elRef.nativeElement.setAttribute(ixControlLabelTag, 'Control1'); + spectator.service.registerControl( + 'control1', + elRef, + ); + + expect(spectator.service.getControlNames()).toEqual(['control1']); + spectator.service.unregisterControl('control1'); + expect(spectator.service.getControlNames()).toEqual([]); }); }); - describe('getControlByName', () => { - it('returns control by name', () => { - expect(spectator.service.getControlNames()).toEqual(['test_control_2']); + it('registers section control', () => { + const ngControl = new MockNgControl(); + const formSection = { + label(): string { return 'Form Section'; }, + } as IxFormSectionComponent; + spectator.service.registerSectionControl( + ngControl, + formSection, + ); + + testScheduler.run(({ expectObservable }) => { + expectObservable(spectator.service.controlSections$).toBe('a', { + a: [ + { section: formSection, controls: [ngControl] }, + ], + }); }); - }); - describe('getElementByControlName', () => { - it('returns element by control name', () => { - expect(spectator.service.getElementByControlName('test_control_2')).toEqual({ - id: 'test_element_2', + spectator.service.unregisterSectionControl(formSection, ngControl); + testScheduler.run(({ expectObservable }) => { + expectObservable(spectator.service.controlSections$).toBe('a', { + a: [], }); }); }); diff --git a/src/app/modules/forms/ix-forms/services/ix-form.service.ts b/src/app/modules/forms/ix-forms/services/ix-form.service.ts index 7ad6aa8a009..a2978b3270e 100644 --- a/src/app/modules/forms/ix-forms/services/ix-form.service.ts +++ b/src/app/modules/forms/ix-forms/services/ix-form.service.ts @@ -7,13 +7,13 @@ import { ixControlLabelTag } from 'app/modules/forms/ix-forms/directives/registe @Injectable({ providedIn: 'root' }) export class IxFormService { - private controls = new Map(); - private sections = new Map(); + private readonly controls = new Map(); + private readonly sections = new Map(); private readonly controlNamesWithlabels = new BehaviorSubject([]); private readonly controlSections = new BehaviorSubject([]); - controlNamesWithLabels$: Observable = this.controlNamesWithlabels.asObservable(); - controlSections$: Observable = this.controlSections.asObservable(); + readonly controlNamesWithLabels$: Observable = this.controlNamesWithlabels.asObservable(); + readonly controlSections$: Observable = this.controlSections.asObservable(); getControlNames(): (string | number | null)[] { return [...this.controls.keys()]; diff --git a/src/app/modules/forms/ix-forms/services/ix-formatter.service.ts b/src/app/modules/forms/ix-forms/services/ix-formatter.service.ts index 22d823cfe1d..789db29d939 100644 --- a/src/app/modules/forms/ix-forms/services/ix-formatter.service.ts +++ b/src/app/modules/forms/ix-forms/services/ix-formatter.service.ts @@ -185,7 +185,7 @@ export class IxFormatterService { } return unitStr.charAt(0).toUpperCase() + 'iB'; } - return undefined; + return ''; }; /** diff --git a/src/app/modules/forms/ix-forms/validators/file-validator/file-validator.service.spec.ts b/src/app/modules/forms/ix-forms/validators/file-validator/file-validator.service.spec.ts new file mode 100644 index 00000000000..8efc840432b --- /dev/null +++ b/src/app/modules/forms/ix-forms/validators/file-validator/file-validator.service.spec.ts @@ -0,0 +1,49 @@ +import { FormControl, ValidatorFn } from '@angular/forms'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { MiB } from 'app/constants/bytes.constant'; +import { fakeFile } from 'app/core/testing/utils/fake-file.uitls'; +import { ixManualValidateError } from 'app/modules/forms/ix-forms/components/ix-errors/ix-errors.component'; +import { FileValidatorService } from 'app/modules/forms/ix-forms/validators/file-validator/file-validator.service'; + +describe('FileValidatorService', () => { + let spectator: SpectatorService; + const maxSizeInBytes = 10 * MiB; + let validatorFn: ValidatorFn; + + const createService = createServiceFactory({ + service: FileValidatorService, + }); + beforeEach(() => { + spectator = createService(); + + validatorFn = spectator.service.maxSize(maxSizeInBytes); + }); + + it('should return null if value is null', () => { + const control = new FormControl(null as File[] | null); + expect(validatorFn(control)).toBeNull(); + }); + + it('should return null if there are no files in the array', () => { + const control = new FormControl([] as File[]); + expect(validatorFn(control)).toBeNull(); + }); + + it('should return null if all files are within size limit', () => { + const file1 = fakeFile('file1.txt', 2 * MiB); + + const control = new FormControl([file1]); + expect(validatorFn(control)).toBeNull(); + }); + + it('should return an error object if any file exceeds the size limit', () => { + const file1 = fakeFile('file1.txt', 11 * MiB); + + const control = new FormControl([file1]); + expect(validatorFn(control)).toEqual({ + [ixManualValidateError]: { + message: 'Maximum file size is limited to 10 MiB.', + }, + }); + }); +}); diff --git a/src/app/modules/forms/ix-forms/validators/file-validator/file-validator.service.ts b/src/app/modules/forms/ix-forms/validators/file-validator/file-validator.service.ts new file mode 100644 index 00000000000..121181428e9 --- /dev/null +++ b/src/app/modules/forms/ix-forms/validators/file-validator/file-validator.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { FormControl, ValidationErrors } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { buildNormalizedFileSize } from 'app/helpers/file-size.utils'; +import { ixManualValidateError } from 'app/modules/forms/ix-forms/components/ix-errors/ix-errors.component'; + +@Injectable({ + providedIn: 'root', +}) +export class FileValidatorService { + constructor( + private translate: TranslateService, + ) {} + + maxSize(maxSizeInBytes: number) { + return (control: FormControl): ValidationErrors | null => { + const files = control.value; + if (!files?.length) { + return null; + } + + for (const file of files) { + if (file.size > maxSizeInBytes) { + return { + [ixManualValidateError]: { + message: this.translate.instant( + 'Maximum file size is limited to {maxSize}.', + { maxSize: buildNormalizedFileSize(maxSizeInBytes) }, + ), + }, + }; + } + } + return null; + }; + } +} diff --git a/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts b/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts index 193a561f8f4..f9ad068c150 100644 --- a/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts +++ b/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts @@ -25,19 +25,19 @@ export function matchOthersFgValidator( } } if (errFields.length) { - fg.get(controlName).setErrors({ + subjectControl.setErrors({ matchOther: errMsg ? { message: errMsg } : true, }); return { [controlName]: { matchOther: errMsg ? { message: errMsg } : true }, }; } - let prevErrors = { ...fg.get(controlName).errors }; + let prevErrors = { ...subjectControl.errors }; delete prevErrors.matchOther; if (isEmpty(prevErrors)) { prevErrors = null; } - fg.get(controlName).setErrors(prevErrors); + subjectControl.setErrors(prevErrors); return null; }; } @@ -66,19 +66,19 @@ export function doesNotEqualFgValidator( } } if (errFields.length) { - fg.get(controlName).setErrors({ + subjectControl.setErrors({ matchesOther: errMsg ? { message: errMsg } : true, }); return { [controlName]: { matchesOther: errMsg ? { message: errMsg } : true }, }; } - let prevErrors = { ...fg.get(controlName).errors }; + let prevErrors = { ...subjectControl.errors }; delete prevErrors.matchesOther; if (isEmpty(prevErrors)) { prevErrors = null; } - fg.get(controlName).setErrors(prevErrors); + subjectControl.setErrors(prevErrors); return null; }; } diff --git a/src/app/modules/forms/ix-forms/validators/validators.ts b/src/app/modules/forms/ix-forms/validators/validators.ts index 03a464dfed5..a6c25e688a9 100644 --- a/src/app/modules/forms/ix-forms/validators/validators.ts +++ b/src/app/modules/forms/ix-forms/validators/validators.ts @@ -1,5 +1,5 @@ import { - FormControl, FormGroup, UntypedFormControl, ValidationErrors, ValidatorFn, + FormGroup, UntypedFormControl, ValidationErrors, ValidatorFn, } from '@angular/forms'; import { isEmpty, isNumber, toNumber } from 'lodash-es'; @@ -50,44 +50,3 @@ export function greaterThanFg( return null; }; } - -export function rangeValidator(min: number, max?: number): ValidatorFn { - let thisControl: FormControl; - - return function rangeValidate(control: FormControl) { - let regex; - if (min === 0) { - regex = /^(0|[1-9]\d*)$/; - } else { - regex = /^[1-9]\d*$/; - } - - if (!control.parent) { - return null; - } - - // Initializing the validator. - if (!thisControl) { - thisControl = control; - } - - if (!thisControl.value) { - return null; - } - - if (regex.test(thisControl.value)) { - const num = Number(thisControl.value); - if (num >= min) { - if (max) { - if (num <= max) { - return null; - } - } else { - return null; - } - } - } - - return { range: true, rangeValue: { min, max } }; - }; -} diff --git a/src/app/modules/forms/search-input/components/advanced-search/advanced-search.component.ts b/src/app/modules/forms/search-input/components/advanced-search/advanced-search.component.ts index fb6a60435e3..67ea879970b 100644 --- a/src/app/modules/forms/search-input/components/advanced-search/advanced-search.component.ts +++ b/src/app/modules/forms/search-input/components/advanced-search/advanced-search.component.ts @@ -55,7 +55,7 @@ export class AdvancedSearchComponent implements OnInit { readonly switchToBasic = output(); readonly runSearch = output(); - private readonly inputArea: Signal> = viewChild('inputArea', { read: ElementRef }); + private readonly inputArea: Signal> = viewChild.required('inputArea', { read: ElementRef }); protected hasQueryErrors = false; protected queryInputValue: string; diff --git a/src/app/modules/forms/toolbar-slider/toolbar-slider.component.ts b/src/app/modules/forms/toolbar-slider/toolbar-slider.component.ts index 0583ac708c0..3bd6f9049b4 100644 --- a/src/app/modules/forms/toolbar-slider/toolbar-slider.component.ts +++ b/src/app/modules/forms/toolbar-slider/toolbar-slider.component.ts @@ -23,7 +23,7 @@ export class ToolbarSliderComponent { readonly label = input(1); readonly name = input(1); - readonly value = model(); + readonly value = model.required(); onChange(updatedValue: string): void { this.value.set(Number(updatedValue)); diff --git a/src/app/modules/global-search/components/global-search-results/global-search-results.component.html b/src/app/modules/global-search/components/global-search-results/global-search-results.component.html index 7459c816b5e..3b38bde1bc4 100644 --- a/src/app/modules/global-search/components/global-search-results/global-search-results.component.html +++ b/src/app/modules/global-search/components/global-search-results/global-search-results.component.html @@ -7,7 +7,7 @@

@if (getElementsBySection(section.value)?.length) {
@if (getLimitedSectionResults(section.value); as sectionResults) { - @for (result of sectionResults; track trackById(i, result); let i = $index) { + @for (result of sectionResults; track result.anchor; let i = $index) {
{ const mockResults: UiSearchableElement[] = [ ...['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'].map((adjustment) => ({ ...mockedUiElement, + anchor: adjustment, hierarchy: [...mockedUiElement.hierarchy, adjustment], })), ]; diff --git a/src/app/modules/global-search/components/global-search-results/global-search-results.component.ts b/src/app/modules/global-search/components/global-search-results/global-search-results.component.ts index d278c02a69b..348a562e5d4 100644 --- a/src/app/modules/global-search/components/global-search-results/global-search-results.component.ts +++ b/src/app/modules/global-search/components/global-search-results/global-search-results.component.ts @@ -12,7 +12,6 @@ import { Option } from 'app/interfaces/option.interface'; import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; import { AuthService } from 'app/modules/auth/auth.service'; import { GlobalSearchSection } from 'app/modules/global-search/enums/global-search-section.enum'; -import { generateIdFromHierarchy } from 'app/modules/global-search/helpers/generate-id-from-hierarchy'; import { processHierarchy } from 'app/modules/global-search/helpers/process-hierarchy'; import { UiSearchableElement } from 'app/modules/global-search/interfaces/ui-searchable-element.interface'; import { GlobalSearchSectionsProvider } from 'app/modules/global-search/services/global-search-sections.service'; @@ -46,7 +45,6 @@ export class GlobalSearchResultsComponent implements OnChanges { readonly GlobalSearchSection = GlobalSearchSection; readonly initialResultsLimit = this.globalSearchSectionsProvider.globalSearchInitialLimit; readonly trackBySection: TrackByFunction> = (_, section) => section.value; - readonly trackById: TrackByFunction = (_, item) => generateIdFromHierarchy(item.hierarchy); processHierarchy = processHierarchy; diff --git a/src/app/modules/global-search/components/global-search-trigger/global-search-trigger.component.ts b/src/app/modules/global-search/components/global-search-trigger/global-search-trigger.component.ts index b71c912bd03..bc889124461 100644 --- a/src/app/modules/global-search/components/global-search-trigger/global-search-trigger.component.ts +++ b/src/app/modules/global-search/components/global-search-trigger/global-search-trigger.component.ts @@ -36,7 +36,7 @@ import { FocusService } from 'app/services/focus.service'; ], }) export class GlobalSearchTriggerComponent implements AfterViewInit { - protected overlayRef: OverlayRef; + protected overlayRef: OverlayRef | undefined; constructor( private cdr: ChangeDetectorRef, @@ -64,7 +64,7 @@ export class GlobalSearchTriggerComponent implements AfterViewInit { } protected showOverlay(): void { - if (this.overlayRef.hasAttached()) { + if (!this.overlayRef || this.overlayRef.hasAttached()) { return; } @@ -86,7 +86,7 @@ export class GlobalSearchTriggerComponent implements AfterViewInit { } private detachOverlay(): void { - if (!this.overlayRef.hasAttached()) { + if (!this.overlayRef?.hasAttached()) { return; } this.overlayRef.detach(); diff --git a/src/app/modules/global-search/interfaces/ui-searchable-element.interface.ts b/src/app/modules/global-search/interfaces/ui-searchable-element.interface.ts index 2ecdd7a8411..d398b15c611 100644 --- a/src/app/modules/global-search/interfaces/ui-searchable-element.interface.ts +++ b/src/app/modules/global-search/interfaces/ui-searchable-element.interface.ts @@ -2,11 +2,9 @@ import { Role } from 'app/enums/role.enum'; import { GlobalSearchSection } from 'app/modules/global-search/enums/global-search-section.enum'; import { GlobalSearchVisibleToken } from 'app/modules/global-search/enums/global-search-visible-token.enum'; -export interface UiSearchableElement { - hierarchy?: string[]; +interface UiSearchableElementBase { section?: GlobalSearchSection; anchorRouterLink?: string[]; - anchor?: string; triggerAnchor?: string; synonyms?: string[]; requiredRoles?: Role[]; @@ -16,3 +14,15 @@ export interface UiSearchableElement { manualRenderElements?: Record; visibleTokens?: GlobalSearchVisibleToken[]; } + +export type UiSearchableElementWithHierarchy = UiSearchableElementBase & { + hierarchy: string[]; + anchor?: string; +}; + +export type UiSearchableElementWithAnchor = UiSearchableElementBase & { + hierarchy?: never; + anchor: string; +}; + +export type UiSearchableElement = UiSearchableElementWithHierarchy | UiSearchableElementWithAnchor; diff --git a/src/app/modules/ix-table/classes/base-data-provider.ts b/src/app/modules/ix-table/classes/base-data-provider.ts index e4c7a89a829..179a4744955 100644 --- a/src/app/modules/ix-table/classes/base-data-provider.ts +++ b/src/app/modules/ix-table/classes/base-data-provider.ts @@ -28,7 +28,7 @@ export class BaseDataProvider implements DataProvider { } currentPage$ = new BehaviorSubject([]); - expandedRow$ = new BehaviorSubject(null); + expandedRow$ = new BehaviorSubject(null); expandedRow: T; totalRows = 0; diff --git a/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.html b/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.html index 7cd40895de6..1b861a81ea3 100644 --- a/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.html +++ b/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.html @@ -2,8 +2,8 @@
@if (isLoading()) { diff --git a/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.ts b/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.ts index 831fd5f0795..8d2ea154a78 100644 --- a/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.ts +++ b/src/app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.ts @@ -42,7 +42,7 @@ export class IxTableEmptyRowComponent implements AfterViewInit { type: EmptyType.NoPageData, }); - readonly templatePortalContent = viewChild>('templatePortalContent'); + readonly templatePortalContent = viewChild.required>('templatePortalContent'); templatePortal: TemplatePortal; constructor( @@ -57,8 +57,9 @@ export class IxTableEmptyRowComponent implements AfterViewInit { } doAction(): void { - if (this.conf().button.action) { - this.conf().button.action(); + const action = this.conf().button?.action; + if (action) { + action(); } } diff --git a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.html b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.html index 4bae36c777c..d14afdfad6f 100644 --- a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.html +++ b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.html @@ -1,4 +1,4 @@ {{ value | scheduleDescription }} +>{{ value | cast | scheduleDescription }} diff --git a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.ts b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.ts index 7334faff5a1..3fa11e38876 100644 --- a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.ts +++ b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatTooltip } from '@angular/material/tooltip'; import { ScheduleDescriptionPipe } from 'app/modules/dates/pipes/schedule-description/schedule-description.pipe'; import { ColumnComponent, Column } from 'app/modules/ix-table/interfaces/column-component.class'; +import { CastPipe } from 'app/modules/pipes/cast/cast.pipe'; import { TestDirective } from 'app/modules/test-id/test.directive'; @Component({ @@ -9,7 +10,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; templateUrl: './ix-cell-schedule.component.html', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [TestDirective, ScheduleDescriptionPipe, MatTooltip], + imports: [TestDirective, ScheduleDescriptionPipe, MatTooltip, CastPipe], }) export class IxCellScheduleComponent extends ColumnComponent {} diff --git a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.html b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.html index 56a27b3f5a9..98f0edf0ab4 100644 --- a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.html +++ b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.html @@ -10,7 +10,7 @@ (click)="$event.stopPropagation(); onButtonClick()" > {{ state() }} - @if (warnings?.length > 0) { + @if (warnings.length > 0) {
diff --git a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component.html b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component.html index 8635ae8b5d4..f807f209f9f 100644 --- a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component.html +++ b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component.html @@ -1,4 +1,4 @@ {{ value }} diff --git a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-toggle/ix-cell-toggle.component.ts b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-toggle/ix-cell-toggle.component.ts index 03e4e5decec..732d6a90ce7 100644 --- a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-toggle/ix-cell-toggle.component.ts +++ b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-toggle/ix-cell-toggle.component.ts @@ -24,7 +24,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; export class IxCellToggleComponent extends ColumnComponent { requiredRoles: Role[]; onRowToggle: (row: T, checked: boolean, toggle: MatSlideToggle) => void; - dynamicRequiredRoles: (row: T) => Observable; + dynamicRequiredRoles?: (row: T) => Observable; get checked(): boolean { return this.value as boolean; diff --git a/src/app/modules/ix-table/components/ix-table-columns-selector/ix-table-columns-selector.component.ts b/src/app/modules/ix-table/components/ix-table-columns-selector/ix-table-columns-selector.component.ts index d2975273915..866f18536c1 100644 --- a/src/app/modules/ix-table/components/ix-table-columns-selector/ix-table-columns-selector.component.ts +++ b/src/app/modules/ix-table/components/ix-table-columns-selector/ix-table-columns-selector.component.ts @@ -30,7 +30,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; ], }) export class IxTableColumnsSelectorComponent implements OnChanges { - readonly columns = model>[]>(); + readonly columns = model.required>[]>(); readonly columnsChange = output>[]>(); diff --git a/src/app/modules/ix-table/directives/ix-body-cell.directive.ts b/src/app/modules/ix-table/directives/ix-body-cell.directive.ts index 452ee7496a0..8d0a434d6c3 100644 --- a/src/app/modules/ix-table/directives/ix-body-cell.directive.ts +++ b/src/app/modules/ix-table/directives/ix-body-cell.directive.ts @@ -14,7 +14,7 @@ import { Column, ColumnComponent, ColumnKeys } from 'app/modules/ix-table/interf standalone: true, }) export class IxTableBodyCellDirective implements AfterViewInit, OnChanges { - readonly row = input(); + readonly row = input.required(); readonly column = input.required>>(); private componentRef: ComponentRef>; diff --git a/src/app/modules/ix-tree/nested-tree-datasource.ts b/src/app/modules/ix-tree/nested-tree-datasource.ts index 98634362ed6..838990b9f94 100644 --- a/src/app/modules/ix-tree/nested-tree-datasource.ts +++ b/src/app/modules/ix-tree/nested-tree-datasource.ts @@ -11,7 +11,7 @@ import { */ export class NestedTreeDataSource extends DataSource { filterPredicate: (data: T[], query: string) => T[]; - sortComparer: (a: T, b: T) => number; + sortComparer?: (a: T, b: T) => number; private filterValue: string; private readonly filterChanged$ = new BehaviorSubject(''); private readonly _data = new BehaviorSubject([]); diff --git a/src/app/modules/jobs/components/job-item/job-item.component.html b/src/app/modules/jobs/components/job-item/job-item.component.html index 4edf209eb73..68705bc4526 100644 --- a/src/app/modules/jobs/components/job-item/job-item.component.html +++ b/src/app/modules/jobs/components/job-item/job-item.component.html @@ -1,8 +1,8 @@
diff --git a/src/app/modules/jobs/components/jobs-panel/jobs-panel.component.html b/src/app/modules/jobs/components/jobs-panel/jobs-panel.component.html index 37e53eb4a53..162f098f42b 100644 --- a/src/app/modules/jobs/components/jobs-panel/jobs-panel.component.html +++ b/src/app/modules/jobs/components/jobs-panel/jobs-panel.component.html @@ -37,7 +37,7 @@

{{ 'Running Jobs' | translate }}

} @else { @let jobs = availableJobs$ | async; - @if (jobs.length) { + @if (jobs?.length) {
@for (job of jobs; track job.id) { { isLoading: boolean; isPanelOpen: boolean; - error: string; + error: string | null; } export const adapter = createEntityAdapter({ @@ -19,7 +19,7 @@ export const adapter = createEntityAdapter({ sortComparer: (a, b) => b.time_started.$date - a.time_started.$date, }); -export const jobsInitialState = adapter.getInitialState({ +export const jobsInitialState: JobsState = adapter.getInitialState({ isLoading: false, isPanelOpen: false, error: null, diff --git a/src/app/modules/jobs/store/job.selectors.ts b/src/app/modules/jobs/store/job.selectors.ts index 60cc6744cf1..2de41460368 100644 --- a/src/app/modules/jobs/store/job.selectors.ts +++ b/src/app/modules/jobs/store/job.selectors.ts @@ -28,7 +28,7 @@ export const selectAllNonTransientJobs = createSelector( * * If you need this behaviour, add extra `observeJob()` operator after `select()`. */ -export const selectJob = (id: number): MemoizedSelector => createSelector( +export const selectJob = (id: number): MemoizedSelector => createSelector( selectJobs, (jobs) => jobs.find((job) => job.id === id), ); diff --git a/src/app/modules/language/translations/translations-loaded.guard.ts b/src/app/modules/language/translations/translations-loaded.guard.ts index c8de8dba5f4..6cb6d3b18af 100644 --- a/src/app/modules/language/translations/translations-loaded.guard.ts +++ b/src/app/modules/language/translations/translations-loaded.guard.ts @@ -5,7 +5,7 @@ import { catchError, map, timeout, } from 'rxjs/operators'; import { LanguageService } from 'app/modules/language/language.service'; -import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; /** * Ensures that translations have been loaded. @@ -18,9 +18,9 @@ export class TranslationsLoadedGuard { isConnected = false; constructor( private languageService: LanguageService, - private wsHandler: WebSocketHandlerService, + private wsStatus: WebSocketStatusService, ) { - this.wsHandler.isConnected$.pipe(untilDestroyed(this)).subscribe((isConnected) => { + this.wsStatus.isConnected$.pipe(untilDestroyed(this)).subscribe((isConnected) => { this.isConnected = isConnected; }); } diff --git a/src/app/modules/layout/console-footer/console-footer.component.ts b/src/app/modules/layout/console-footer/console-footer.component.ts index 31e052a9c8c..6e70724b296 100644 --- a/src/app/modules/layout/console-footer/console-footer.component.ts +++ b/src/app/modules/layout/console-footer/console-footer.component.ts @@ -17,7 +17,7 @@ import { ConsolePanelDialogComponent } from 'app/modules/layout/console-footer/c imports: [AsyncPipe], }) export class ConsoleFooterComponent implements OnInit { - private readonly messageContainer: Signal> = viewChild('messageContainer', { read: ElementRef }); + private readonly messageContainer: Signal> = viewChild.required('messageContainer', { read: ElementRef }); lastThreeLogLines$ = this.messagesStore.lastThreeLogLines$; diff --git a/src/app/modules/layout/topbar/change-password-dialog/change-password-dialog.component.ts b/src/app/modules/layout/topbar/change-password-dialog/change-password-dialog.component.ts index 7fad7ec6e40..7b6498d6436 100644 --- a/src/app/modules/layout/topbar/change-password-dialog/change-password-dialog.component.ts +++ b/src/app/modules/layout/topbar/change-password-dialog/change-password-dialog.component.ts @@ -40,7 +40,7 @@ import { ApiService } from 'app/modules/websocket/api.service'; ], }) export class ChangePasswordDialogComponent { - form = this.fb.group({ + form = this.fb.nonNullable.group({ old_password: [''], new_password: ['', [Validators.required]], passwordConfirmation: ['', [Validators.required]], @@ -81,8 +81,8 @@ export class ChangePasswordDialogComponent { onSubmit(): void { this.api.call('user.set_password', [{ - old_password: this.form.value.old_password, - new_password: this.form.value.new_password, + old_password: this.form.getRawValue().old_password, + new_password: this.form.getRawValue().new_password, username: this.loggedInUser.pw_name, }]).pipe( this.loader.withLoader(), diff --git a/src/app/modules/layout/topbar/user-menu/user-menu.component.html b/src/app/modules/layout/topbar/user-menu/user-menu.component.html index 3da97aba57a..100c247e948 100644 --- a/src/app/modules/layout/topbar/user-menu/user-menu.component.html +++ b/src/app/modules/layout/topbar/user-menu/user-menu.component.html @@ -22,7 +22,7 @@ - @if (user?.account_attributes.includes(AccountAttribute.Local)) { + @if (user?.account_attributes?.includes(AccountAttribute.Local)) {

diff --git a/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts b/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts index a2a759dfb56..2168c61efd5 100644 --- a/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts +++ b/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts @@ -215,7 +215,7 @@ describe('SlideInComponent', () => { message: 'You have unsaved changes. Are you sure you want to close?', cancelText: 'No', buttonText: 'Yes', - buttonColor: 'red', + buttonColor: 'warn', hideCheckbox: true, }); discardPeriodicTasks(); @@ -236,14 +236,7 @@ describe('SlideInComponent', () => { 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, - }); + expect(spectator.inject(DialogService).confirm).not.toHaveBeenCalled(); discardPeriodicTasks(); })); }); diff --git a/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts b/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts index e38e0478f57..b4c33b041ac 100644 --- a/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts +++ b/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts @@ -218,7 +218,7 @@ export class SlideInComponent implements OnInit, OnDestroy { 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', + buttonColor: 'warn', hideCheckbox: true, }); } diff --git a/src/app/modules/slide-ins/slide-in.ts b/src/app/modules/slide-ins/slide-in.ts index 94c41dface5..fcbf7e7bb22 100644 --- a/src/app/modules/slide-ins/slide-in.ts +++ b/src/app/modules/slide-ins/slide-in.ts @@ -73,10 +73,18 @@ export class SlideIn extends ComponentStore { return close$.asObservable().pipe(tap(() => this.focusService.restoreFocus())); } - popComponent = this.updater((state, componentId: string | undefined) => { + popComponent = this.updater((state, id: string) => { const newMap = new Map(state.components); - const lastComponent = this.getAliveComponents(newMap).pop(); - const id = componentId || lastComponent.id; + newMap.set(id, { ...newMap.get(id), isComponentAlive: false }); + this.focusOnTheCloseButton(); + return { + components: newMap, + }; + }); + + closeLast = this.updater((state) => { + const newMap = new Map(state.components); + const { id } = this.getAliveComponents(newMap).pop(); newMap.set(id, { ...newMap.get(id), isComponentAlive: false }); this.focusOnTheCloseButton(); return { diff --git a/src/app/modules/truecommand/components/truecommand-connect-modal/truecommand-connect-modal.component.ts b/src/app/modules/truecommand/components/truecommand-connect-modal/truecommand-connect-modal.component.ts index a877a08e05d..0748dbb5f05 100644 --- a/src/app/modules/truecommand/components/truecommand-connect-modal/truecommand-connect-modal.component.ts +++ b/src/app/modules/truecommand/components/truecommand-connect-modal/truecommand-connect-modal.component.ts @@ -81,7 +81,10 @@ export class TruecommandConnectModalComponent implements OnInit { : helptextTopbar.updateDialog.connect_btn; if (this.data.isConnected) { - this.form.patchValue(this.data.config); + this.form.patchValue({ + ...this.data.config, + api_key: this.data.config.api_key || '', + }); this.cdr.markForCheck(); } } diff --git a/src/app/modules/websocket/api.service.ts b/src/app/modules/websocket/api.service.ts index 596e13559ad..ffa3952252e 100644 --- a/src/app/modules/websocket/api.service.ts +++ b/src/app/modules/websocket/api.service.ts @@ -34,6 +34,7 @@ import { import { Job } from 'app/interfaces/job.interface'; import { SubscriptionManagerService } from 'app/modules/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; @Injectable({ providedIn: 'root', @@ -43,10 +44,11 @@ export class ApiService { constructor( protected wsHandler: WebSocketHandlerService, + protected wsStatus: WebSocketStatusService, protected subscriptionManager: SubscriptionManagerService, protected translate: TranslateService, ) { - this.wsHandler.isConnected$?.subscribe((isConnected) => { + this.wsStatus.isConnected$?.subscribe((isConnected) => { if (!isConnected) { this.clearSubscriptions(); } diff --git a/src/app/modules/websocket/ping.service.spec.ts b/src/app/modules/websocket/ping.service.spec.ts index 95d77e61a13..e451ccb8dd4 100644 --- a/src/app/modules/websocket/ping.service.spec.ts +++ b/src/app/modules/websocket/ping.service.spec.ts @@ -1,9 +1,9 @@ import { discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; import { BehaviorSubject, of } from 'rxjs'; -import { AuthService } from 'app/modules/auth/auth.service'; import { PingService } from 'app/modules/websocket/ping.service'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; describe('PingService', () => { let spectator: SpectatorService; @@ -15,9 +15,9 @@ describe('PingService', () => { providers: [ mockProvider(WebSocketHandlerService, { scheduleCall: jest.fn(), - isConnected$: of(true), }), - mockProvider(AuthService, { + mockProvider(WebSocketStatusService, { + isConnected$: of(true), isAuthenticated$, }), ], diff --git a/src/app/modules/websocket/ping.service.ts b/src/app/modules/websocket/ping.service.ts index fe86feca98c..4298e2a21fd 100644 --- a/src/app/modules/websocket/ping.service.ts +++ b/src/app/modules/websocket/ping.service.ts @@ -4,8 +4,8 @@ import { UUID } from 'angular2-uuid'; import { filter, interval, switchMap, tap, } from 'rxjs'; -import { AuthService } from 'app/modules/auth/auth.service'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; @UntilDestroy() @Injectable({ @@ -16,14 +16,14 @@ export class PingService { constructor( private wsHandler: WebSocketHandlerService, - private authService: AuthService, + private wsStatus: WebSocketStatusService, ) {} setupPing(): void { interval(this.pingTimeoutMillis).pipe( - switchMap(() => this.wsHandler.isConnected$), + switchMap(() => this.wsStatus.isConnected$), filter(Boolean), - switchMap(() => this.authService.isAuthenticated$), + switchMap(() => this.wsStatus.isAuthenticated$), filter(Boolean), tap(() => this.wsHandler.scheduleCall({ id: UUID.UUID(), diff --git a/src/app/modules/websocket/subscription-manager.service.spec.ts b/src/app/modules/websocket/subscription-manager.service.spec.ts index 9c66b06343a..9a943276166 100644 --- a/src/app/modules/websocket/subscription-manager.service.spec.ts +++ b/src/app/modules/websocket/subscription-manager.service.spec.ts @@ -1,8 +1,9 @@ import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; import { UUID } from 'angular2-uuid'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { SubscriptionManagerService } from 'app/modules/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; describe('SubscriptionManagerService', () => { let spectator: SpectatorService; @@ -16,6 +17,12 @@ describe('SubscriptionManagerService', () => { responses$, scheduleCall: jest.fn(), }), + mockProvider(WebSocketStatusService, { + isConnected$: of(true), + isAuthenticated$: of(true), + isConnected: true, + isAuthenticated: true, + }), ], }); diff --git a/src/app/modules/websocket/subscription-manager.service.ts b/src/app/modules/websocket/subscription-manager.service.ts index b8be6932725..59143d46818 100644 --- a/src/app/modules/websocket/subscription-manager.service.ts +++ b/src/app/modules/websocket/subscription-manager.service.ts @@ -6,6 +6,7 @@ import { import { isCollectionUpdateMessage, isSuccessfulResponse } from 'app/helpers/api.helper'; import { ApiEventMethod, ApiEventTyped, CollectionUpdateMessage } from 'app/interfaces/api-message.interface'; import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; type Method = ApiEventMethod | `${ApiEventMethod}:${string}`; @@ -35,6 +36,7 @@ export class SubscriptionManagerService { private subscriptionsToClose = new Set(); constructor( + private wsStatus: WebSocketStatusService, private wsHandler: WebSocketHandlerService, ) { this.listenForSubscriptionsToBeEstablished(); @@ -122,11 +124,14 @@ export class SubscriptionManagerService { private cancelSubscription(method: Method): void { const backendSubscriptionId = this.establishedSubscriptions.get(method); - this.wsHandler.scheduleCall({ - id: UUID.UUID(), - method: 'core.unsubscribe', - params: [backendSubscriptionId], - }); + const isAuthenticated = this.wsStatus.isAuthenticated; + if (isAuthenticated) { + this.wsHandler.scheduleCall({ + id: UUID.UUID(), + method: 'core.unsubscribe', + params: [backendSubscriptionId], + }); + } this.establishedSubscriptions.delete(method); this.openSubscriptions.delete(method); diff --git a/src/app/modules/websocket/websocket-handler.service.ts b/src/app/modules/websocket/websocket-handler.service.ts index 912b61d875d..cf12840ec5b 100644 --- a/src/app/modules/websocket/websocket-handler.service.ts +++ b/src/app/modules/websocket/websocket-handler.service.ts @@ -17,12 +17,15 @@ import { timer, } from 'rxjs'; import { webSocket as rxjsWebSocket } from 'rxjs/webSocket'; -import { makeRequestMessage } from 'app/helpers/api.helper'; +import { isErrorResponse, makeRequestMessage } from 'app/helpers/api.helper'; import { WEBSOCKET } from 'app/helpers/websocket.helper'; import { WINDOW } from 'app/helpers/window.helper'; -import { RequestMessage, IncomingMessage } from 'app/interfaces/api-message.interface'; +import { + RequestMessage, IncomingMessage, +} from 'app/interfaces/api-message.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { WebSocketConnection } from 'app/modules/websocket/websocket-connection.class'; +import { WebSocketStatusService } from 'app/services/websocket-status.service'; type ApiCall = Required>; @@ -34,9 +37,6 @@ export class WebSocketHandlerService { private readonly wsConnection: WebSocketConnection = new WebSocketConnection(this.webSocket); private connectionUrl = (this.window.location.protocol === 'https:' ? 'wss://' : 'ws://') + environment.remote + '/api/current'; - private readonly connectionEstablished$ = new BehaviorSubject(false); - readonly isConnected$ = this.connectionEstablished$.asObservable(); - private readonly reconnectTimeoutMillis = 5 * 1000; private reconnectTimerSubscription: Subscription | undefined; private readonly maxConcurrentCalls = 20; @@ -71,9 +71,8 @@ export class WebSocketHandlerService { private showingConcurrentCallsError = false; private callsInConcurrentCallsError = new Set(); - private subscriptionIds: Record = {}; - constructor( + private wsStatus: WebSocketStatusService, private dialogService: DialogService, private translate: TranslateService, @Inject(WINDOW) protected window: Window, @@ -90,7 +89,7 @@ export class WebSocketHandlerService { private setupScheduledCalls(): void { combineLatest([ this.triggerNextCall$, - this.isConnected$, + this.wsStatus.isConnected$, ]).pipe( filter(([, isConnected]) => isConnected), tap(() => { @@ -114,7 +113,11 @@ export class WebSocketHandlerService { return this.responses$.pipe( filter((message) => 'id' in message && message.id === call.id), take(1), - tap(() => { + tap((message) => { + // Following `if` block needs to be removed once NAS-131829 is resolved + if (isErrorResponse(message)) { + console.error('Error: ', message.error); + } this.activeCalls--; this.pendingCalls.delete(call.id); this.triggerNextCall$.next(); @@ -178,7 +181,7 @@ export class WebSocketHandlerService { } private onClose(event: CloseEvent): void { - this.connectionEstablished$.next(false); + this.wsStatus.setConnectionStatus(false); this.isConnectionLive$.next(false); if (this.reconnectTimerSubscription) { return; @@ -203,7 +206,7 @@ export class WebSocketHandlerService { return; } this.shutDownInProgress = false; - this.connectionEstablished$.next(true); + this.wsStatus.setConnectionStatus(true); performance.mark('WS Connected'); performance.measure('Establishing WS connection', 'WS Init', 'WS Connected'); diff --git a/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html b/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html index 738d3f52f18..5d90b1fc015 100644 --- a/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html +++ b/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html @@ -33,7 +33,7 @@

@@ -43,7 +43,7 @@

@@ -56,7 +56,7 @@

{{ 'App Version' | translate }}:
@if (!isCustomApp()) { - {{ app()?.metadata?.app_version | appVersion | orNotAvailable }} + {{ app().metadata?.app_version | appVersion | orNotAvailable }} } @else if (app().human_version) { {{ app().human_version?.split(':')?.[1]?.split('_')?.[0] }} @@ -67,7 +67,7 @@

{{ 'Version' | translate }}:
@if (!isCustomApp()) { - {{ app()?.version | appVersion | orNotAvailable }} + {{ app().version | appVersion | orNotAvailable }} }

@@ -75,7 +75,7 @@

{{ 'Source' | translate }}:
- @for (source of app()?.metadata?.sources; track source; let last = $last) { + @for (source of app().metadata?.sources; track source; let last = $last) {
{{ 'Train' | translate }}:
- {{ app()?.metadata?.train | orNotAvailable }} + {{ app().metadata?.train | orNotAvailable }}
@if ((app().portals | keyvalue).length > 0 ) { diff --git a/src/app/pages/apps/components/installed-apps/app-info-card/app-info-card.component.ts b/src/app/pages/apps/components/installed-apps/app-info-card/app-info-card.component.ts index 9ab6cc70236..3a162366709 100644 --- a/src/app/pages/apps/components/installed-apps/app-info-card/app-info-card.component.ts +++ b/src/app/pages/apps/components/installed-apps/app-info-card/app-info-card.component.ts @@ -78,7 +78,7 @@ import { RedirectService } from 'app/services/redirect.service'; ], }) export class AppInfoCardComponent { - readonly app = input(); + readonly app = input.required(); readonly startApp = output(); readonly stopApp = output(); protected readonly isCustomApp = computed(() => this.app()?.metadata?.name === customApp); diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts index 8ee1cdc18e7..8d4077c1cc5 100644 --- a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts @@ -339,10 +339,11 @@ export class InstalledAppsListComponent implements OnInit { } openStatusDialog(name: string): void { - if (!this.appJobs.has(name)) { + const jobId = this.appJobs.get(name)?.id; + if (!jobId) { return; } - const job$ = this.store$.select(selectJob(this.appJobs.get(name).id)); + const job$ = this.store$.select(selectJob(jobId)); this.dialogService.jobDialog(job$, { title: name, canMinimize: true }) .afterClosed() .pipe(this.errorHandler.catchError(), untilDestroyed(this)) diff --git a/src/app/pages/apps/components/installed-apps/installed-apps.elements.ts b/src/app/pages/apps/components/installed-apps/installed-apps.elements.ts index 92194f65037..2db96a53d1c 100644 --- a/src/app/pages/apps/components/installed-apps/installed-apps.elements.ts +++ b/src/app/pages/apps/components/installed-apps/installed-apps.elements.ts @@ -7,6 +7,7 @@ export const installedAppsElements = { anchorRouterLink: ['/apps', 'installed'], elements: { installed: { + anchor: 'installed-apps-list', synonyms: [T('Apps'), T('Applications')], }, }, diff --git a/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts b/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts index 5d25681cbaf..92f0565aabf 100644 --- a/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts +++ b/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts @@ -46,7 +46,7 @@ import { ErrorHandlerService } from 'app/services/error-handler.service'; export class SelectPoolDialogComponent implements OnInit { readonly requiredRoles = [Role.FullAdmin]; - form = this.formBuilder.group({ + form = this.formBuilder.nonNullable.group({ pool: [''], migrateApplications: [false], }); @@ -72,7 +72,7 @@ export class SelectPoolDialogComponent implements OnInit { } onSubmit(): void { - this.dockerStore.setDockerPool(this.form.value.pool).pipe( + this.dockerStore.setDockerPool(this.form.getRawValue().pool).pipe( untilDestroyed(this), ).subscribe(() => { this.snackbar.success( diff --git a/src/app/pages/audit/audit.component.ts b/src/app/pages/audit/audit.component.ts index d6370387526..47e9294c78d 100644 --- a/src/app/pages/audit/audit.component.ts +++ b/src/app/pages/audit/audit.component.ts @@ -57,7 +57,7 @@ import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors'; export class AuditComponent implements OnInit, OnDestroy { protected dataProvider: AuditApiDataProvider; - protected readonly masterDetailView = viewChild(MasterDetailViewComponent); + protected readonly masterDetailView = viewChild.required(MasterDetailViewComponent); protected readonly controllerTypeControl = new FormControl(ControllerType.Active); protected readonly controllerTypeOptions$ = of(mapToOptions(controllerTypeLabels, this.translate)); protected readonly controllerType = toSignal(this.controllerTypeControl.value$); diff --git a/src/app/pages/audit/audit.elements.ts b/src/app/pages/audit/audit.elements.ts index e45a10af2f4..0edd064f6dd 100644 --- a/src/app/pages/audit/audit.elements.ts +++ b/src/app/pages/audit/audit.elements.ts @@ -6,6 +6,7 @@ export const auditElements = { anchorRouterLink: ['/system', 'audit'], elements: { audit: { + anchor: 'audit-list', synonyms: [T('Logs')], }, }, diff --git a/src/app/pages/audit/components/audit-list/audit-list.component.html b/src/app/pages/audit/components/audit-list/audit-list.component.html index 81daab28cf4..163c553bc86 100644 --- a/src/app/pages/audit/components/audit-list/audit-list.component.html +++ b/src/app/pages/audit/components/audit-list/audit-list.component.html @@ -20,7 +20,7 @@ detailsRowIdentifier="audit_id" [columns]="columns" [dataProvider]="dataProvider()" - [isLoading]="dataProvider().isLoading$ | async" + [isLoading]="!!(dataProvider().isLoading$ | async)" (expanded)="expanded($event)" > (auditServiceLabels.has(row.service) - ? this.translate.instant(auditServiceLabels.get(row.service)) - : row.service || '-'), + getValue: (row) => { + const service = auditServiceLabels.get(row.service); + return service ? this.translate.instant(service) : row.service || '-'; + }, }), textColumn({ title: this.translate.instant('User'), @@ -75,9 +76,10 @@ export class AuditListComponent { textColumn({ title: this.translate.instant('Event'), propertyName: 'event', - getValue: (row) => (auditEventLabels.has(row.event) - ? this.translate.instant(auditEventLabels.get(row.event)) - : row.event || '-'), + getValue: (row) => { + const event = auditEventLabels.get(row.event); + return event ? this.translate.instant(event) : row.event || '-'; + }, }), textColumn({ title: this.translate.instant('Event Data'), @@ -101,7 +103,7 @@ export class AuditListComponent { getUserAvatarForLog(row: AuditEntry): SafeHtml { // eslint-disable-next-line sonarjs/no-angular-bypass-sanitization - return this.sanitizer.bypassSecurityTrustHtml(toSvg(row.username, this.isMobileView ? 15 : 35)); + return this.sanitizer.bypassSecurityTrustHtml(toSvg(row.username, this.isMobileView() ? 15 : 35)); } expanded(row: AuditEntry): void { diff --git a/src/app/pages/audit/components/audit-search/audit-search.component.html b/src/app/pages/audit/components/audit-search/audit-search.component.html index e1c23580e1b..38a883062d5 100644 --- a/src/app/pages/audit/components/audit-search/audit-search.component.html +++ b/src/app/pages/audit/components/audit-search/audit-search.component.html @@ -1,7 +1,7 @@

} - @if (isReady && !report()?.errorConf) { + @if (isReady && !report().errorConf) {
diff --git a/src/app/pages/reports-dashboard/components/report/report.component.ts b/src/app/pages/reports-dashboard/components/report/report.component.ts index e857292d0f5..92bb212e412 100644 --- a/src/app/pages/reports-dashboard/components/report/report.component.ts +++ b/src/app/pages/reports-dashboard/components/report/report.component.ts @@ -90,8 +90,8 @@ export class ReportComponent implements OnInit, OnChanges { private readonly lineChart = viewChild(LineChartComponent); - updateReport$ = new BehaviorSubject>(null); - fetchReport$ = new BehaviorSubject(null); + updateReport$ = new BehaviorSubject | null>(null); + fetchReport$ = new BehaviorSubject(null); autoRefreshTimer: Subscription; autoRefreshEnabled: boolean; isReady = false; @@ -105,11 +105,11 @@ export class ReportComponent implements OnInit, OnChanges { stepBackDisabled = false; timezone: string; lastEndDateForCurrentZoomLevel = { - '60m': null as number, - '24h': null as number, - '7d': null as number, - '1M': null as number, - '6M': null as number, + '60m': null as number | null, + '24h': null as number | null, + '7d': null as number | null, + '1M': null as number | null, + '6M': null as number | null, }; currentStartDate: number; @@ -130,7 +130,8 @@ export class ReportComponent implements OnInit, OnChanges { get reportTitle(): string { const trimmed = this.report().title.replace(/[()]/g, ''); - return this.identifier() ? trimmed.replace(/{identifier}/, this.identifier()) : this.report().title; + const identifier = this.identifier(); + return identifier ? trimmed.replace(/{identifier}/, identifier) : this.report().title; } get currentZoomLevel(): ReportZoomLevel { @@ -277,10 +278,6 @@ export class ReportComponent implements OnInit, OnChanges { this.customZoom = true; } - setChartInteractive(value: boolean): void { - this.isActive = value; - } - timeZoomReset(): void { this.zoomLevelIndex = this.zoomLevelMax; const rrdOptions = this.convertTimeSpan(this.currentZoomLevel); diff --git a/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.spec.ts b/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.spec.ts new file mode 100644 index 00000000000..67f1a5b30e6 --- /dev/null +++ b/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.spec.ts @@ -0,0 +1,159 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { + fakeAsync, flush, flushMicrotasks, tick, +} from '@angular/core/testing'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatMenuHarness } from '@angular/material/menu/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + createComponentFactory, + mockProvider, + Spectator, +} from '@ngneat/spectator/jest'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; +import { IxSelectHarness } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.harness'; +import { IxSlideToggleHarness } from 'app/modules/forms/ix-forms/components/ix-slide-toggle/ix-slide-toggle.harness'; +import { + ReportsGlobalControlsComponent, +} from 'app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component'; +import { ReportTab, ReportType } from 'app/pages/reports-dashboard/interfaces/report-tab.interface'; +import { ReportsService } from 'app/pages/reports-dashboard/reports.service'; +import { autoRefreshReportsToggled } from 'app/store/preferences/preferences.actions'; +import { selectPreferences } from 'app/store/preferences/preferences.selectors'; + +describe('ReportsGlobalControlsComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + const createComponent = createComponentFactory({ + component: ReportsGlobalControlsComponent, + providers: [ + mockProvider(ReportsService, { + getReportGraphs: jest.fn(() => of([])), + getReportTabs: jest.fn(() => [ + { label: 'Disk', value: ReportType.Disk }, + { label: 'CPU', value: ReportType.Cpu }, + { label: 'UPS', value: ReportType.Ups }, + ] as ReportTab[]), + getDiskDevices: jest.fn(() => of([ + { label: 'sda', value: 'sda' }, + { label: 'sdb', value: 'sdb' }, + ])), + getDiskMetrics: jest.fn(() => of([ + { label: 'Disk I/O', value: 'disk' }, + { label: 'Disk Temperature', value: 'disktemp' }, + ])), + }), + provideMockStore({ + selectors: [ + { + selector: selectPreferences, + value: { + autoRefreshReports: true, + }, + }, + ], + }), + mockProvider(ActivatedRoute, { + routeConfig: { + path: ReportType.Disk, + }, + snapshot: { + queryParams: { + disks: ['sda'], + }, + }, + }), + ], + }); + + beforeEach(fakeAsync(() => { + spectator = createComponent(); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + })); + + describe('report selector', () => { + it('shows a list of available reports', async () => { + const reportMenu = await loader.getHarness(MatMenuHarness); + + await reportMenu.open(); + const menuItems = await reportMenu.getItems(); + + expect(menuItems).toHaveLength(3); + expect(await menuItems[0].getText()).toBe('Disk'); + expect(await menuItems[1].getText()).toBe('CPU'); + expect(await menuItems[2].getText()).toBe('UPS'); + }); + + it('marks currently selected menu item based on current route', async () => { + const reportMenu = await loader.getHarness(MatMenuHarness); + + expect(await reportMenu.getTriggerText()).toBe('Disk'); + }); + }); + + describe('disk reports', () => { + it('shows disks multiselect when disk report is selected', async () => { + const devices = await loader.getHarness(IxSelectHarness.with({ label: 'Devices' })); + const options = await devices.getOptionLabels(); + + expect(options).toEqual(['sda', 'sdb']); + }); + + it('shows disk metrics when disk report is selected', async () => { + const metrics = await loader.getHarness(IxSelectHarness.with({ label: 'Metrics' })); + const options = await metrics.getOptionLabels(); + + expect(options).toEqual(['Disk I/O', 'Disk Temperature']); + }); + + it('pre-selects disks based on route params', async () => { + const devices = await loader.getHarness(IxSelectHarness.with({ label: 'Devices' })); + + expect(await devices.getValue()).toEqual(['sda']); + }); + + it('emits (diskOptionsChanged) when user changes disk or disk metric selection', fakeAsync(async () => { + jest.spyOn(spectator.component.diskOptionsChanged, 'emit'); + + const devices = await loader.getHarness(IxSelectHarness.with({ label: 'Devices' })); + await devices.setValue(['sdb']); + + flush(1); + flushMicrotasks(); + + tick(1000); + + expect(spectator.component.diskOptionsChanged.emit).toHaveBeenCalledWith({ + devices: ['sdb'], + metrics: ['disk', 'disktemp'], + }); + })); + }); + + describe('Auto Refresh toggle', () => { + it('shows Auto Refresh toggle with current value based on user preferences', async () => { + const autoRefreshToggle = await loader.getHarness(IxSlideToggleHarness.with({ label: 'Auto Refresh' })); + + expect(await autoRefreshToggle.getValue()).toBe(true); + }); + + it('dispatches autoRefreshReportsToggled() action when Auto Refresh is toggled', async () => { + const store$ = spectator.inject(Store); + jest.spyOn(store$, 'dispatch'); + + const autoRefreshToggle = await loader.getHarness(IxSlideToggleHarness.with({ label: 'Auto Refresh' })); + await autoRefreshToggle.toggle(); + + expect(store$.dispatch).toHaveBeenCalledWith(autoRefreshReportsToggled()); + }); + }); + + it('shows Exporters button', async () => { + const exportersButton = await loader.getHarness(MatButtonHarness.with({ text: 'Exporters' })); + + expect(exportersButton).toBeTruthy(); + }); +}); diff --git a/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts b/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts index 7e6a793d2f3..3d025f7d5c3 100644 --- a/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts +++ b/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts @@ -4,7 +4,7 @@ import { ChangeDetectorRef, Component, OnInit, output, } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu'; import { ActivatedRoute, RouterLink } from '@angular/router'; @@ -50,22 +50,22 @@ import { waitForPreferences } from 'app/store/preferences/preferences.selectors' export class ReportsGlobalControlsComponent implements OnInit { readonly diskOptionsChanged = output<{ devices: string[]; metrics: string[] }>(); - form = this.fb.group({ + protected form = this.fb.group({ autoRefresh: [false], devices: [[] as string[]], metrics: [[] as string[]], }); - activeTab: ReportTab; - allTabs: ReportTab[]; - diskDevices$ = this.reportsService.getDiskDevices(); - diskMetrics$ = this.reportsService.getDiskMetrics(); + protected activeTab: ReportTab | undefined; + protected allTabs: ReportTab[]; + protected diskDevices$ = this.reportsService.getDiskDevices(); + protected diskMetrics$ = this.reportsService.getDiskMetrics(); - readonly ReportType = ReportType; - readonly searchableElements = reportingGlobalControlsElements; + protected readonly ReportType = ReportType; + protected readonly searchableElements = reportingGlobalControlsElements; constructor( - private fb: FormBuilder, + private fb: NonNullableFormBuilder, private route: ActivatedRoute, private store$: Store, private reportsService: ReportsService, @@ -78,11 +78,11 @@ export class ReportsGlobalControlsComponent implements OnInit { this.setupDisksTab(); } - isActiveTab(tab: ReportTab): boolean { + protected isActiveTab(tab: ReportTab): boolean { return this.activeTab?.value === tab.value; } - typeTab(tab: ReportTab): ReportTab { + protected typeTab(tab: ReportTab): ReportTab { return tab; } diff --git a/src/app/pages/reports-dashboard/reports-dashboard.elements.ts b/src/app/pages/reports-dashboard/reports-dashboard.elements.ts index a8b9d25d566..fcda59ec0c3 100644 --- a/src/app/pages/reports-dashboard/reports-dashboard.elements.ts +++ b/src/app/pages/reports-dashboard/reports-dashboard.elements.ts @@ -6,6 +6,8 @@ export const reportingElements = { synonyms: [T('Stats')], anchorRouterLink: ['/reportsdashboard'], elements: { - reporting: {}, + reporting: { + anchor: 'reports-dashboard', + }, }, } satisfies UiSearchableElement; diff --git a/src/app/pages/services/components/service-ssh/service-ssh.component.ts b/src/app/pages/services/components/service-ssh/service-ssh.component.ts index c73fd3b9cce..a901503970f 100644 --- a/src/app/pages/services/components/service-ssh/service-ssh.component.ts +++ b/src/app/pages/services/components/service-ssh/service-ssh.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -69,15 +69,15 @@ export class ServiceSshComponent implements OnInit { }; form = this.fb.group({ - tcpport: [null as number], - password_login_groups: [null as string[]], + tcpport: [null as number | null], + password_login_groups: [null as string[] | null], passwordauth: [false], kerberosauth: [false], tcpfwd: [false], bindiface: [[] as string[]], compression: [false], - sftp_log_level: [null as SshSftpLogLevel], - sftp_log_facility: [null as SshSftpLogFacility], + sftp_log_level: [null as SshSftpLogLevel | null], + sftp_log_facility: [null as SshSftpLogFacility | null], weak_ciphers: [[] as SshWeakCipher[]], options: [''], }); @@ -106,7 +106,7 @@ export class ServiceSshComponent implements OnInit { private errorHandler: ErrorHandlerService, private cdr: ChangeDetectorRef, private formErrorHandler: FormErrorHandlerService, - private fb: FormBuilder, + private fb: NonNullableFormBuilder, private dialogService: DialogService, private userService: UserService, private translate: TranslateService, diff --git a/src/app/pages/services/components/service-ups/service-ups.component.ts b/src/app/pages/services/components/service-ups/service-ups.component.ts index 114ade1b8d8..c721386c479 100644 --- a/src/app/pages/services/components/service-ups/service-ups.component.ts +++ b/src/app/pages/services/components/service-ups/service-ups.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Validators, ReactiveFormsModule, NonNullableFormBuilder } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -63,25 +63,25 @@ export class ServiceUpsComponent implements OnInit { isMasterMode = true; form = this.fb.group({ - identifier: [null as string, [Validators.required, Validators.pattern(/^[\w|,|.|\-|_]+$/)]], - mode: [null as UpsMode], - remotehost: [null as string, Validators.required], - remoteport: [null as number, Validators.required], - driver: [null as string, Validators.required], - port: [null as string, Validators.required], - monuser: [null as string, Validators.required], - monpwd: [null as string, Validators.pattern(/^((?![#|\s]).)*$/)], - extrausers: [null as string], + identifier: [null as string | null, [Validators.required, Validators.pattern(/^[\w|,|.|\-|_]+$/)]], + mode: [null as UpsMode | null], + remotehost: [null as string | null, Validators.required], + remoteport: [null as number | null, Validators.required], + driver: [null as string | null, Validators.required], + port: [null as string | null, Validators.required], + monuser: [null as string | null, Validators.required], + monpwd: [null as string | null, Validators.pattern(/^((?![#|\s]).)*$/)], + extrausers: [null as string | null], rmonitor: [false], - shutdown: [null as string], - shutdowntimer: [null as number], - shutdowncmd: [null as string], + shutdown: [null as string | null], + shutdowntimer: [null as number | null], + shutdowncmd: [null as string | null], powerdown: [false], nocommwarntime: [300], hostsync: [15], - description: [null as string], - options: [null as string], - optionsupsd: [null as string], + description: [null as string | null], + options: [null as string | null], + optionsupsd: [null as string | null], }); readonly helptext = helptextServiceUps; @@ -150,7 +150,7 @@ export class ServiceUpsComponent implements OnInit { private formErrorHandler: FormErrorHandlerService, private cdr: ChangeDetectorRef, private errorHandler: ErrorHandlerService, - private fb: FormBuilder, + private fb: NonNullableFormBuilder, private dialogService: DialogService, private translate: TranslateService, private snackbar: SnackbarService, diff --git a/src/app/pages/services/services.elements.ts b/src/app/pages/services/services.elements.ts index 721a0cf75b8..57830a176cd 100644 --- a/src/app/pages/services/services.elements.ts +++ b/src/app/pages/services/services.elements.ts @@ -5,7 +5,9 @@ export const servicesElements = { hierarchy: [T('System'), T('Services')], anchorRouterLink: ['/system', 'services'], elements: { - services: {}, + services: { + anchor: 'services', + }, }, manualRenderElements: { smb: { diff --git a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html index 8c9da5877c0..a0e89ef17c9 100644 --- a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html +++ b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html @@ -10,7 +10,7 @@

@@ -20,7 +20,7 @@

mat-button [ixTest]="['iscsi-share', 'wizard']" [ixUiSearch]="searchableElements.elements.wizard" - (click)="openForm(null, true)" + (click)="openForm(undefined, true)" > {{ 'Wizard' | translate }} @@ -46,7 +46,7 @@

ix-table-body [columns]="columns" [dataProvider]="dataProvider" - [isLoading]="dataProvider.isLoading$ | async" + [isLoading]="!!(dataProvider.isLoading$ | async)" > diff --git a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts index aa503517719..b9d020a655f 100644 --- a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts @@ -79,7 +79,7 @@ export class IscsiCardComponent implements OnInit { Role.SharingWrite, ]; - targets = signal(null); + targets = signal(null); protected readonly searchableElements = iscsiCardElements; @@ -98,9 +98,7 @@ export class IscsiCardComponent implements OnInit { title: this.translate.instant('Mode'), propertyName: 'mode', hidden: true, - getValue: (row) => (iscsiTargetModeNames.has(row.mode) - ? this.translate.instant(iscsiTargetModeNames.get(row.mode)) - : row.mode || '-'), + getValue: (row) => this.translate.instant(iscsiTargetModeNames.get(row.mode) || row.mode) || '-', }), actionsColumn({ actions: [ diff --git a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html index 9795c174d89..a810dc527d1 100644 --- a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html +++ b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html @@ -10,7 +10,7 @@

@@ -46,7 +46,7 @@

ix-table-body [columns]="columns" [dataProvider]="dataProvider" - [isLoading]="dataProvider.isLoading$ | async" + [isLoading]="!!(dataProvider.isLoading$ | async)" > diff --git a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts index 54fd70b60f3..d785ee2b5fb 100644 --- a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts @@ -136,6 +136,8 @@ export class NfsCardComponent implements OnInit { this.dialogService.confirm({ title: this.translate.instant('Confirmation'), message: this.translate.instant('Are you sure you want to delete NFS Share "{path}"?', { path: nfs.path }), + buttonColor: 'warn', + buttonText: this.translate.instant('Delete'), }).pipe( filter(Boolean), switchMap(() => this.api.call('sharing.nfs.delete', [nfs.id])), diff --git a/src/app/pages/sharing/components/shares-dashboard/shares-dashboard.elements.ts b/src/app/pages/sharing/components/shares-dashboard/shares-dashboard.elements.ts index 9166af52e8c..b15e83990c4 100644 --- a/src/app/pages/sharing/components/shares-dashboard/shares-dashboard.elements.ts +++ b/src/app/pages/sharing/components/shares-dashboard/shares-dashboard.elements.ts @@ -7,6 +7,7 @@ export const sharesDashboardElements = { anchorRouterLink: ['/sharing'], elements: { sharing: { + anchor: 'shares-dashboard', synonyms: [ T('Shares'), T('Add Share'), diff --git a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html index 34dae68ec41..4b71c159cf6 100644 --- a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html +++ b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html @@ -10,7 +10,7 @@

@@ -46,7 +46,7 @@

ix-table-body [columns]="columns" [dataProvider]="dataProvider" - [isLoading]="dataProvider.isLoading$ | async" + [isLoading]="!!(dataProvider.isLoading$ | async)" > diff --git a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts index b7f71077361..b3deed86f49 100644 --- a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts @@ -123,7 +123,7 @@ export class SmbCardComponent implements OnInit { { iconName: iconMarker('edit'), tooltip: this.translate.instant('Edit'), - disabled: (row) => this.loadingMap$.pipe(map((ids) => ids.get(row.id))), + disabled: (row) => this.loadingMap$.pipe(map((ids) => Boolean(ids.get(row.id)))), onClick: (row) => this.openForm(row), }, { @@ -170,6 +170,8 @@ export class SmbCardComponent implements OnInit { this.dialogService.confirm({ title: this.translate.instant('Confirmation'), message: this.translate.instant('Are you sure you want to delete SMB Share "{name}"?', { name: smb.name }), + buttonText: this.translate.instant('Delete'), + buttonColor: 'warn', }).pipe( filter(Boolean), switchMap(() => this.api.call('sharing.smb.delete', [smb.id])), diff --git a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts index 3136eeebd3a..fcffd1d85dd 100644 --- a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts +++ b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Validators, ReactiveFormsModule, NonNullableFormBuilder } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -63,7 +63,7 @@ export class AuthorizedAccessFormComponent implements OnInit { } form = this.formBuilder.group({ - tag: [null as number, [Validators.required, Validators.min(0)]], + tag: [null as number | null, [Validators.required, Validators.min(0)]], user: ['', Validators.required], secret: ['', [ Validators.minLength(12), @@ -134,7 +134,7 @@ export class AuthorizedAccessFormComponent implements OnInit { constructor( private translate: TranslateService, - private formBuilder: FormBuilder, + private formBuilder: NonNullableFormBuilder, private errorHandler: FormErrorHandlerService, private cdr: ChangeDetectorRef, private api: ApiService, @@ -191,7 +191,7 @@ export class AuthorizedAccessFormComponent implements OnInit { } onSubmit(): void { - const values = this.form.value; + const values = this.form.getRawValue(); const payload = { tag: values.tag, user: values.user, diff --git a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.html b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.html index 28ff91105b2..d57ba136efb 100644 --- a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.html +++ b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.html @@ -1,5 +1,5 @@ - +

{{ 'Authorized Access' | translate }}

@@ -26,7 +26,7 @@

{{ 'Authorized Access' | translate }}

ix-table-body [columns]="columns" [dataProvider]="dataProvider" - [isLoading]="dataProvider.isLoading$ | async" + [isLoading]="!!(dataProvider.isLoading$ | async)" > diff --git a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts index 38a676d1dd2..76f607ed179 100644 --- a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts +++ b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts @@ -115,6 +115,7 @@ export class AuthorizedAccessListComponent implements OnInit { title: this.translate.instant('Delete'), message: this.translate.instant('Are you sure you want to delete this item?'), buttonText: this.translate.instant('Delete'), + buttonColor: 'warn', }).pipe( filter(Boolean), switchMap(() => this.api.call('iscsi.auth.delete', [row.id]).pipe(this.loader.withLoader())), diff --git a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.elements.ts b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.elements.ts index ab29be003c7..f73243d617d 100644 --- a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.elements.ts +++ b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.elements.ts @@ -5,6 +5,8 @@ export const authorizedAccessListElements = { hierarchy: [T('Shares'), T('iSCSI'), T('Authorized Access')], anchorRouterLink: ['/sharing', 'iscsi', 'authorized-access'], elements: { - list: {}, + list: { + anchor: 'authorized-access-list', + }, }, } satisfies UiSearchableElement; diff --git a/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html b/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html index d9ced51805c..160b517a0de 100644 --- a/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html +++ b/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html @@ -24,7 +24,7 @@

{{ 'Delete iSCSI extent {name}?' | translate: { name: exten diff --git a/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts b/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts index bf92c7cf639..7f2817061b9 100644 --- a/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts +++ b/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts @@ -62,11 +62,12 @@ export class ManageConfigurationMenuComponent { this.matDialog.open(UploadConfigDialogComponent); } - onResetDefaults(): void { + onResetToDefaults(): void { this.dialogService.confirm({ title: helptext.reset_config_form.title, message: helptext.reset_config_form.message, buttonText: helptext.reset_config_form.button_text, + buttonColor: 'warn', }) .pipe( filter(Boolean), diff --git a/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts b/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts index 95c47693199..a198dbf601a 100644 --- a/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts +++ b/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts @@ -131,6 +131,7 @@ export class NtpServerCardComponent implements OnInit { { address: server.address }, ), buttonText: this.translate.instant('Delete'), + buttonColor: 'warn', }).pipe( filter(Boolean), switchMap(() => this.api.call('system.ntpserver.delete', [server.id])), diff --git a/src/app/pages/system/general-settings/support/eula/eula.elements.ts b/src/app/pages/system/general-settings/support/eula/eula.elements.ts index 0fa89935564..5e82dfb69b2 100644 --- a/src/app/pages/system/general-settings/support/eula/eula.elements.ts +++ b/src/app/pages/system/general-settings/support/eula/eula.elements.ts @@ -5,6 +5,8 @@ export const eulaElements = { hierarchy: [T('System'), T('Support'), T('Eula')], anchorRouterLink: ['/system', 'support', 'eula'], elements: { - eula: {}, + eula: { + anchor: 'eula', + }, }, } satisfies UiSearchableElement; diff --git a/src/app/pages/system/general-settings/support/proactive/proactive.component.scss b/src/app/pages/system/general-settings/support/proactive/proactive.component.scss index df41159c5c8..911095a7644 100644 --- a/src/app/pages/system/general-settings/support/proactive/proactive.component.scss +++ b/src/app/pages/system/general-settings/support/proactive/proactive.component.scss @@ -21,3 +21,7 @@ } } } + +.form-actions { + padding: 0 11px; +} diff --git a/src/app/pages/system/general-settings/support/support-card/support-card.elements.ts b/src/app/pages/system/general-settings/support/support-card/support-card.elements.ts index e1e0205aa20..90f84451109 100644 --- a/src/app/pages/system/general-settings/support/support-card/support-card.elements.ts +++ b/src/app/pages/system/general-settings/support/support-card/support-card.elements.ts @@ -5,7 +5,9 @@ export const supportCardElements = { hierarchy: [T('System'), T('Support')], anchorRouterLink: ['/system', 'support'], elements: { - support: {}, + support: { + anchor: 'support', + }, updateLicense: { hierarchy: [T('License')], synonyms: [ diff --git a/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html b/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html index 650626a823e..3c8496c1a62 100644 --- a/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html +++ b/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html @@ -15,7 +15,7 @@

{{ 'Upload Config' | translate }}

*ixRequiresRoles="requiredRoles" mat-button type="submit" - color="primary" + color="warn" ixTest="upload" [disabled]="form.invalid" > diff --git a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.spec.ts b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.spec.ts index caafdc84c52..f2a5e75c640 100644 --- a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.spec.ts +++ b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.spec.ts @@ -16,7 +16,6 @@ import { Preferences } from 'app/interfaces/preferences.interface'; import { SystemInfo } from 'app/interfaces/system-info.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { IxSelectHarness } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.harness'; -import { WebSocketHandlerService } from 'app/modules/websocket/websocket-handler.service'; import { ManualUpdateFormComponent } from 'app/pages/system/update/components/manual-update-form/manual-update-form.component'; import { SystemGeneralService } from 'app/services/system-general.service'; import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors'; @@ -58,9 +57,6 @@ describe('ManualUpdateFormComponent', () => { getItem: () => ProductType.ScaleEnterprise, }, }), - mockProvider(WebSocketHandlerService, { - isConnected$: of(true), - }), provideMockStore({ selectors: [ { diff --git a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts index 375e2e4b1c9..e6db7104098 100644 --- a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts +++ b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts @@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Validators, ReactiveFormsModule, NonNullableFormBuilder } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { MatProgressBar } from '@angular/material/progress-bar'; @@ -75,7 +75,7 @@ export class ManualUpdateFormComponent implements OnInit { protected readonly searchableElements = systemManualUpdateFormElements; isFormLoading$ = new BehaviorSubject(false); - form = this.formBuilder.nonNullable.group({ + form = this.formBuilder.group({ filelocation: ['', Validators.required], updateFile: [null as FileList | null], rebootAfterManualUpdate: [false], @@ -93,7 +93,7 @@ export class ManualUpdateFormComponent implements OnInit { private dialogService: DialogService, protected router: Router, public systemService: SystemGeneralService, - private formBuilder: FormBuilder, + private formBuilder: NonNullableFormBuilder, private api: ApiService, private errorHandler: ErrorHandlerService, private authService: AuthService, diff --git a/src/app/pages/system/update/components/manual-update-form/manual-update-form.elements.ts b/src/app/pages/system/update/components/manual-update-form/manual-update-form.elements.ts index 8857f48aaa1..ebe5665a36e 100644 --- a/src/app/pages/system/update/components/manual-update-form/manual-update-form.elements.ts +++ b/src/app/pages/system/update/components/manual-update-form/manual-update-form.elements.ts @@ -6,6 +6,8 @@ export const systemManualUpdateFormElements = { anchorRouterLink: ['/system', 'update', 'manualupdate'], synonyms: [T('Install Manual Update File'), T('Manual Update'), T('Manual Upgrade'), T('Upload Manual Update File')], elements: { - manualUpdate: {}, + manualUpdate: { + anchor: 'manual-update', + }, }, } satisfies UiSearchableElement; diff --git a/src/app/pages/system/update/update.component.html b/src/app/pages/system/update/update.component.html index 8a326a1ac91..eecc30ba654 100644 --- a/src/app/pages/system/update/update.component.html +++ b/src/app/pages/system/update/update.component.html @@ -20,7 +20,7 @@ {{ package.name }} } - @if ((updateService.packages$ | async).length === 0) { + @if ((updateService.packages$ | async)?.length === 0) { {{ 'No update found.' | translate }} diff --git a/src/app/pages/two-factor-auth/two-factor.elements.ts b/src/app/pages/two-factor-auth/two-factor.elements.ts index 8a7d8232027..5b710f1bf83 100644 --- a/src/app/pages/two-factor-auth/two-factor.elements.ts +++ b/src/app/pages/two-factor-auth/two-factor.elements.ts @@ -5,7 +5,9 @@ export const twoFactorElements = { hierarchy: [T('Credentials'), T('Two-Factor Authentication')], anchorRouterLink: ['/credentials', 'two-factor'], elements: { - twoFactor: {}, + twoFactor: { + anchor: 'two-factor', + }, configure2FaSecret: { hierarchy: [T('Configure 2FA Secret')], anchor: 'configure-2fa-secret', diff --git a/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts b/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts index cf41980cb50..ac0dc197054 100644 --- a/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts +++ b/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts @@ -7,7 +7,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { pickBy } from 'lodash-es'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { VirtualizationDeviceType, VirtualizationGpuType, VirtualizationType } from 'app/enums/virtualization.enum'; +import { VirtualizationDeviceType, VirtualizationGpuType } from 'app/enums/virtualization.enum'; import { AvailableUsb, VirtualizationDevice, @@ -42,7 +42,7 @@ import { ErrorHandlerService } from 'app/services/error-handler.service'; export class AddDeviceMenuComponent { private readonly usbChoices = toSignal(this.api.call('virt.device.usb_choices'), { initialValue: {} }); // TODO: Stop hardcoding params - private readonly gpuChoices = toSignal(this.api.call('virt.device.gpu_choices', [VirtualizationType.Container, VirtualizationGpuType.Physical]), { initialValue: {} }); + private readonly gpuChoices = toSignal(this.api.call('virt.device.gpu_choices', [VirtualizationGpuType.Physical]), { initialValue: {} }); protected readonly isLoadingDevices = this.deviceStore.isLoading; diff --git a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html index e1ed8a4287a..02751cc20de 100644 --- a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html +++ b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html @@ -16,6 +16,14 @@ [required]="true" > + +
@@ -224,34 +232,32 @@ } - @if ((usbDevices$ | async); as usbDevices) { - @if (usbDevices.length > 0) { - 0) { + + - - - } + [options]="usbDevices$" + > + } - @if ((gpuDevices$ | async); as gpuDevices) { - @if (gpuDevices.length > 0) { - 0) { + + - - - } + [options]="gpuDevices$" + > + }
diff --git a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts index 3418b6fad98..6c1c75763f7 100644 --- a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts +++ b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts @@ -1,6 +1,7 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { @@ -25,6 +26,7 @@ import { VirtualizationInstance } from 'app/interfaces/virtualization.interface' import { AuthService } from 'app/modules/auth/auth.service'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { IxCheckboxHarness } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.harness'; +import { IxIconGroupHarness } from 'app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.harness'; import { IxListHarness } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.harness'; import { IxFormHarness } from 'app/modules/forms/ix-forms/testing/ix-form.harness'; import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component'; @@ -34,8 +36,7 @@ import { InstanceWizardComponent } from 'app/pages/virtualization/components/ins import { VirtualizationImageWithId } from 'app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component'; import { FilesystemService } from 'app/services/filesystem.service'; -// TODO: https://ixsystems.atlassian.net/browse/NAS-133118 -describe.skip('InstanceWizardComponent', () => { +describe('InstanceWizardComponent', () => { let spectator: SpectatorRouting; let loader: HarnessLoader; let form: IxFormHarness; @@ -57,7 +58,15 @@ describe.skip('InstanceWizardComponent', () => { autostart: false, cpu: 'Intel Xeon', memory: 2 * GiB, - } as VirtualizationInstance]), + }, + { + id: 'testVM', + name: 'testVM', + type: VirtualizationType.Vm, + autostart: false, + cpu: 'Intel Xeon', + memory: 4 * GiB, + }] as VirtualizationInstance[]), mockCall('interface.has_pending_changes', false), mockCall('virt.device.nic_choices', { nic1: 'nic1', @@ -113,18 +122,98 @@ describe.skip('InstanceWizardComponent', () => { expect(spectator.inject(MatDialog).open).toHaveBeenCalled(); expect(await form.getValues()).toMatchObject({ - Image: 'Almalinux 8 Cloud', + Image: 'almalinux/8/cloud', + }); + }); + + it('creates new container instance when form is submitted', async () => { + await form.fillForm({ + Name: 'new', + 'CPU Configuration': '1-2', + 'Memory Size': '1 GiB', }); + + const browseButton = await loader.getHarness(MatButtonHarness.with({ text: 'Browse Catalog' })); + await browseButton.click(); + + const diskList = await loader.getHarness(IxListHarness.with({ label: 'Disks' })); + await diskList.pressAddButton(); + const diskForm = await diskList.getLastListItem(); + await diskForm.fillForm({ + Source: '/mnt/source', + Destination: 'destination', + }); + + const proxiesList = await loader.getHarness(IxListHarness.with({ label: 'Proxies' })); + await proxiesList.pressAddButton(); + const proxyForm = await proxiesList.getLastListItem(); + await proxyForm.fillForm({ + 'Host Port': 3000, + 'Host Protocol': 'TCP', + 'Instance Port': 2000, + 'Instance Protocol': 'UDP', + }); + + // TODO: Fix this to use IxCheckboxHarness + const usbDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ + label: 'xHCI Host Controller (0003)', + })); + await usbDeviceCheckbox.check(); + + const useDefaultNetworkCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'Use default network settings' })); + await useDefaultNetworkCheckbox.setValue(false); + + // TODO: Fix this to use IxCheckboxHarness + const nicDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'nic1' })); + await nicDeviceCheckbox.check(); + + // TODO: Fix this to use IxCheckboxHarness + const gpuDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'NVIDIA GeForce GTX 1080' })); + await gpuDeviceCheckbox.check(); + + const createButton = await loader.getHarness(MatButtonHarness.with({ text: 'Create' })); + await createButton.click(); + + expect(spectator.inject(ApiService).job).toHaveBeenCalledWith('virt.instance.create', [{ + name: 'new', + autostart: true, + cpu: '1-2', + instance_type: VirtualizationType.Container, + devices: [ + { + dev_type: VirtualizationDeviceType.Disk, + source: '/mnt/source', + destination: 'destination', + }, + { + dev_type: VirtualizationDeviceType.Proxy, + source_port: 3000, + source_proto: VirtualizationProxyProtocol.Tcp, + dest_port: 2000, + dest_proto: VirtualizationProxyProtocol.Udp, + }, + { dev_type: VirtualizationDeviceType.Nic, nic_type: VirtualizationNicType.Bridged, parent: 'nic1' }, + { dev_type: VirtualizationDeviceType.Usb, product_id: '0003' }, + { dev_type: VirtualizationDeviceType.Gpu, pci: 'pci_0000_01_00_0' }, + ], + image: 'almalinux/8/cloud', + memory: GiB, + environment: {}, + }]); + expect(spectator.inject(DialogService).jobDialog).toHaveBeenCalled(); + expect(spectator.inject(SnackbarService).success).toHaveBeenCalled(); }); - it('creates new instance when form is submitted', async () => { + it('creates new vm instance when form is submitted', async () => { await form.fillForm({ Name: 'new', - Autostart: true, 'CPU Configuration': '1-2', 'Memory Size': '1 GiB', }); + const instanceType = await loader.getHarness(IxIconGroupHarness.with({ label: 'Virtualization Method' })); + await instanceType.setValue('VM'); + const browseButton = await loader.getHarness(MatButtonHarness.with({ text: 'Browse Catalog' })); await browseButton.click(); @@ -146,17 +235,22 @@ describe.skip('InstanceWizardComponent', () => { 'Instance Protocol': 'UDP', }); - const usbDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'xHCI Host Controller (0003)' })); - await usbDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const usbDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ + label: 'xHCI Host Controller (0003)', + })); + await usbDeviceCheckbox.check(); const useDefaultNetworkCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'Use default network settings' })); await useDefaultNetworkCheckbox.setValue(false); - const nicDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'nic1' })); - await nicDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const nicDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'nic1' })); + await nicDeviceCheckbox.check(); - const gpuDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'NVIDIA GeForce GTX 1080' })); - await gpuDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const gpuDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'NVIDIA GeForce GTX 1080' })); + await gpuDeviceCheckbox.check(); const createButton = await loader.getHarness(MatButtonHarness.with({ text: 'Create' })); await createButton.click(); @@ -165,6 +259,7 @@ describe.skip('InstanceWizardComponent', () => { name: 'new', autostart: true, cpu: '1-2', + instance_type: VirtualizationType.Vm, devices: [ { dev_type: VirtualizationDeviceType.Disk, @@ -193,7 +288,6 @@ describe.skip('InstanceWizardComponent', () => { it('sends no NIC devices when default network settings checkbox is set', async () => { await form.fillForm({ Name: 'new', - Autostart: true, 'CPU Configuration': '1-2', 'Memory Size': '1 GiB', }); @@ -204,10 +298,12 @@ describe.skip('InstanceWizardComponent', () => { const useDefaultNetworkCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'Use default network settings' })); await useDefaultNetworkCheckbox.setValue(false); - const nicDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'nic1' })); - await nicDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const nicDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'nic1' })); + await nicDeviceCheckbox.check(); await useDefaultNetworkCheckbox.setValue(true); // no nic1 should be send now + spectator.detectChanges(); const createButton = await loader.getHarness(MatButtonHarness.with({ text: 'Create' })); await createButton.click(); @@ -220,6 +316,7 @@ describe.skip('InstanceWizardComponent', () => { image: 'almalinux/8/cloud', memory: GiB, environment: {}, + instance_type: 'CONTAINER', }]); expect(spectator.inject(DialogService).jobDialog).toHaveBeenCalled(); expect(spectator.inject(SnackbarService).success).toHaveBeenCalled(); diff --git a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts index 807e77dbb99..a2ed66c7ff9 100644 --- a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts +++ b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts @@ -14,6 +14,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { + filter, map, Observable, of, } from 'rxjs'; import { Role } from 'app/enums/role.enum'; @@ -26,6 +27,8 @@ import { virtualizationProxyProtocolLabels, VirtualizationRemote, VirtualizationType, + virtualizationTypeIcons, + virtualizationTypeLabels, } from 'app/enums/virtualization.enum'; import { mapToOptions } from 'app/helpers/options.helper'; import { containersHelptext } from 'app/helptext/virtualization/containers'; @@ -42,6 +45,7 @@ import { IxCheckboxListComponent } from 'app/modules/forms/ix-forms/components/i import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFormGlossaryComponent } from 'app/modules/forms/ix-forms/components/ix-form-glossary/ix-form-glossary.component'; import { IxFormSectionComponent } from 'app/modules/forms/ix-forms/components/ix-form-section/ix-form-section.component'; +import { IxIconGroupComponent } from 'app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component'; import { IxListComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.component'; @@ -64,23 +68,24 @@ import { FilesystemService } from 'app/services/filesystem.service'; selector: 'ix-instance-wizard', standalone: true, imports: [ - PageHeaderComponent, - IxInputComponent, - ReactiveFormsModule, - TranslateModule, - IxCheckboxComponent, - MatButton, - TestDirective, - ReadOnlyComponent, AsyncPipe, - IxListComponent, + IxCheckboxComponent, + IxCheckboxListComponent, + IxExplorerComponent, IxFormGlossaryComponent, IxFormSectionComponent, - IxCheckboxListComponent, + IxInputComponent, + IxListComponent, IxListItemComponent, IxSelectComponent, - IxExplorerComponent, + MatButton, NgxSkeletonLoaderModule, + PageHeaderComponent, + ReactiveFormsModule, + ReadOnlyComponent, + TestDirective, + TranslateModule, + IxIconGroupComponent, ], templateUrl: './instance-wizard.component.html', styleUrls: ['./instance-wizard.component.scss'], @@ -90,6 +95,8 @@ export class InstanceWizardComponent { protected readonly isLoading = signal(false); protected readonly requiredRoles = [Role.VirtGlobalWrite]; protected readonly VirtualizationNicType = VirtualizationNicType; + protected readonly virtualizationTypeOptions$ = of(mapToOptions(virtualizationTypeLabels, this.translate)); + protected readonly virtualizationTypeIcons = virtualizationTypeIcons; protected readonly hasPendingInterfaceChanges = toSignal(this.api.call('interface.has_pending_changes')); @@ -109,10 +116,9 @@ export class InstanceWizardComponent { }))), ); - // TODO: MV supports only [Container, Physical] for now (based on the response) gpuDevices$ = this.api.call( 'virt.device.gpu_choices', - [VirtualizationType.Container, VirtualizationGpuType.Physical], + [VirtualizationGpuType.Physical], ).pipe( map((choices) => Object.entries(choices).map(([pci, gpu]) => ({ label: gpu.description, @@ -121,10 +127,11 @@ export class InstanceWizardComponent { ); protected readonly form = this.formBuilder.nonNullable.group({ - name: ['', Validators.required], - image: ['', Validators.required], + name: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(200)]], + instance_type: [VirtualizationType.Container, Validators.required], + image: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(200)]], cpu: ['', [cpuValidator()]], - memory: [null as number | null], + memory: [null as number], use_default_network: [true], usb_devices: [[] as string[]], gpu_devices: [[] as string[]], @@ -132,9 +139,9 @@ export class InstanceWizardComponent { mac_vlan_nics: [[] as string[]], proxies: this.formBuilder.array; - source_port: FormControl; + source_port: FormControl; dest_proto: FormControl; - dest_port: FormControl; + dest_port: FormControl; }>>([]), disks: this.formBuilder.array; @@ -167,25 +174,22 @@ export class InstanceWizardComponent { minWidth: '90vw', data: { remote: VirtualizationRemote.LinuxContainers, + type: this.form.controls.instance_type.value, }, }) .afterClosed() - .pipe(untilDestroyed(this)) + .pipe(filter(Boolean), untilDestroyed(this)) .subscribe((image: VirtualizationImageWithId) => { - if (!image) { - return; - } - this.form.controls.image.setValue(image.id); }); } protected addProxy(): void { - const control = this.formBuilder.nonNullable.group({ + const control = this.formBuilder.group({ source_proto: [VirtualizationProxyProtocol.Tcp], - source_port: [null as number | null, Validators.required], + source_port: [null as number, Validators.required], dest_proto: [VirtualizationProxyProtocol.Tcp], - dest_port: [null as number | null, Validators.required], + dest_port: [null as number, Validators.required], }); this.form.controls.proxies.push(control); @@ -196,7 +200,7 @@ export class InstanceWizardComponent { } protected addDisk(): void { - const control = this.formBuilder.nonNullable.group({ + const control = this.formBuilder.group({ source: ['', Validators.required], destination: ['', Validators.required], }); @@ -228,7 +232,7 @@ export class InstanceWizardComponent { } addEnvironmentVariable(): void { - const control = this.formBuilder.nonNullable.group({ + const control = this.formBuilder.group({ name: ['', Validators.required], value: ['', Validators.required], }); @@ -246,6 +250,7 @@ export class InstanceWizardComponent { return { devices, autostart: true, + instance_type: this.form.controls.instance_type.value, name: this.form.controls.name.value, cpu: this.form.controls.cpu.value, memory: this.form.controls.memory.value, @@ -297,23 +302,26 @@ export class InstanceWizardComponent { dev_type: VirtualizationDeviceType.Gpu, }); } - const macVlanNics: { parent: string; dev_type: VirtualizationDeviceType; nic_type: VirtualizationNicType }[] = []; - for (const parent of this.form.controls.mac_vlan_nics.value) { - macVlanNics.push({ - parent, - dev_type: VirtualizationDeviceType.Nic, - nic_type: VirtualizationNicType.Macvlan, - }); + if (!this.form.controls.use_default_network.value) { + for (const parent of this.form.controls.mac_vlan_nics.value) { + macVlanNics.push({ + parent, + dev_type: VirtualizationDeviceType.Nic, + nic_type: VirtualizationNicType.Macvlan, + }); + } } const bridgedNics: { parent: string; dev_type: VirtualizationDeviceType; nic_type: VirtualizationNicType }[] = []; - for (const parent of this.form.controls.bridged_nics.value) { - bridgedNics.push({ - parent, - dev_type: VirtualizationDeviceType.Nic, - nic_type: VirtualizationNicType.Bridged, - }); + if (!this.form.controls.use_default_network.value) { + for (const parent of this.form.controls.bridged_nics.value) { + bridgedNics.push({ + parent, + dev_type: VirtualizationDeviceType.Nic, + nic_type: VirtualizationNicType.Bridged, + }); + } } const proxies = this.form.controls.proxies.value.map((proxy) => ({ diff --git a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts index 2ae49a09c2a..c3b0e7f0f2d 100644 --- a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts +++ b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts @@ -12,7 +12,7 @@ import { mockCall, mockApi, } from 'app/core/testing/utils/mock-api.utils'; -import { VirtualizationRemote } from 'app/enums/virtualization.enum'; +import { VirtualizationRemote, VirtualizationType } from 'app/enums/virtualization.enum'; import { VirtualizationImage } from 'app/interfaces/virtualization.interface'; import { IxFormHarness } from 'app/modules/forms/ix-forms/testing/ix-form.harness'; import { ApiService } from 'app/modules/websocket/api.service'; @@ -25,6 +25,7 @@ const imageChoices: Record = { release: '8', archs: ['arm64'], variant: 'cloud', + instance_types: [VirtualizationType.Container], } as VirtualizationImage, 'alpine/3.18/default': { label: 'Alpine 3.18 (armhf, default)', @@ -32,6 +33,7 @@ const imageChoices: Record = { release: '3.18', archs: ['armhf'], variant: 'default', + instance_types: [VirtualizationType.Container], } as VirtualizationImage, } as Record; @@ -47,7 +49,10 @@ describe('SelectImageDialogComponent', () => { mockProvider(MatDialogRef), { provide: MAT_DIALOG_DATA, - useValue: { remote: VirtualizationRemote.LinuxContainers }, + useValue: { + remote: VirtualizationRemote.LinuxContainers, + type: VirtualizationType.Container, + }, }, ], }); diff --git a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts index 52ee27272d3..7822dffdc9d 100644 --- a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts +++ b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, Inject, signal, OnInit, + computed, } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton, MatIconButton } from '@angular/material/button'; @@ -11,7 +12,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { catchError, Observable, of } from 'rxjs'; import { EmptyType } from 'app/enums/empty-type.enum'; -import { VirtualizationRemote } from 'app/enums/virtualization.enum'; +import { VirtualizationRemote, VirtualizationType } from 'app/enums/virtualization.enum'; import { EmptyConfig } from 'app/interfaces/empty-config.interface'; import { Option } from 'app/interfaces/option.interface'; import { VirtualizationImage } from 'app/interfaces/virtualization.interface'; @@ -71,13 +72,17 @@ export class SelectImageDialogComponent implements OnInit { large: true, } as EmptyConfig); + protected isContainer = computed(() => { + return this.data.type === VirtualizationType.Container; + }); + constructor( private api: ApiService, private dialogRef: MatDialogRef, private fb: FormBuilder, private translate: TranslateService, private errorHandler: ErrorHandlerService, - @Inject(MAT_DIALOG_DATA) protected data: { remote: VirtualizationRemote }, + @Inject(MAT_DIALOG_DATA) protected data: { remote: VirtualizationRemote; type: VirtualizationType }, ) { this.filterForm.valueChanges.pipe(untilDestroyed(this)).subscribe(() => this.filterImages()); } @@ -95,7 +100,7 @@ export class SelectImageDialogComponent implements OnInit { } private getImages(): void { - this.api.call('virt.instance.image_choices', [this.data]) + this.api.call('virt.instance.image_choices', [{ remote: this.data.remote }]) .pipe( catchError((error: unknown) => { this.errorHandler.showErrorModal(error); @@ -114,7 +119,10 @@ export class SelectImageDialogComponent implements OnInit { const variantSet = new Set(); const releaseSet = new Set(); - const imageArray = Object.entries(images).map(([id, image]) => ({ ...image, id })); + const imageArray = Object.entries(images) + .filter(([_, image]) => image?.instance_types?.includes(this.data.type)) + .map(([id, image]) => ({ ...image, id })); + this.images.set(imageArray); imageArray.forEach((image) => { diff --git a/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html b/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html index b3254db7379..1f93ed37d89 100644 --- a/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html +++ b/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html @@ -51,7 +51,7 @@

{{ 'Delete' | translate }}

*ixRequiresRoles="requiredRoles" type="submit" mat-button - color="primary" + color="warn" ixTest="delete-device" [disabled]="form.invalid" > diff --git a/src/app/pages/vm/devices/device-list/device-list/device-list.component.html b/src/app/pages/vm/devices/device-list/device-list/device-list.component.html index 3e126548efb..6c1c61f0b87 100644 --- a/src/app/pages/vm/devices/device-list/device-list/device-list.component.html +++ b/src/app/pages/vm/devices/device-list/device-list/device-list.component.html @@ -20,7 +20,7 @@ ix-table-body [columns]="columns" [dataProvider]="dataProvider" - [isLoading]="dataProvider.isLoading$ | async" + [isLoading]="!!(dataProvider.isLoading$ | async)" > , + public slideInRef: SlideInRef, ) { this.slideInRef.requireConfirmationWhen(() => { return of(this.form.dirty); diff --git a/src/app/pages/vm/vm-list.elements.ts b/src/app/pages/vm/vm-list.elements.ts index 39d8cb3ea59..ee79d63a8f9 100644 --- a/src/app/pages/vm/vm-list.elements.ts +++ b/src/app/pages/vm/vm-list.elements.ts @@ -7,6 +7,7 @@ export const vmListElements = { anchorRouterLink: ['/vm'], elements: { vm: { + anchor: 'vm-list', synonyms: [T('VM'), T('Virtualization')], }, add: { diff --git a/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html b/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html index 15efed6bb8d..c5c4a21d86b 100644 --- a/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html +++ b/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html @@ -30,7 +30,7 @@

{{ 'Delete Virtual Machine' | translate }}