diff --git a/src/app/pages/apps/components/installed-apps/installed-apps.component.html b/src/app/pages/apps/components/installed-apps/installed-apps.component.html index b97850158ca..81da8cf1f7b 100644 --- a/src/app/pages/apps/components/installed-apps/installed-apps.component.html +++ b/src/app/pages/apps/components/installed-apps/installed-apps.component.html @@ -48,7 +48,6 @@

{{ 'Applications' | translate }}

mat-button ixTest="bulk-actions-menu" [matMenuTriggerFor]="menu" - [disabled]="!hasCheckedApps" > {{ 'Select action' | translate }} diff --git a/src/app/pages/virtualization/components/all-instances/all-instances.component.scss b/src/app/pages/virtualization/components/all-instances/all-instances.component.scss index 5c5e895c881..310a11b8abd 100644 --- a/src/app/pages/virtualization/components/all-instances/all-instances.component.scss +++ b/src/app/pages/virtualization/components/all-instances/all-instances.component.scss @@ -87,7 +87,6 @@ } .table-container { - background: var(--bg2); box-sizing: border-box; display: flex; flex: 1; diff --git a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.html b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.html new file mode 100644 index 00000000000..f18cfb90a6d --- /dev/null +++ b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.html @@ -0,0 +1,49 @@ +
+
+ {{ checkedInstances().length }} + {{ 'Selected' | translate }} +
+ +
+ + +
+ + + + + + +
diff --git a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.scss b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.scss new file mode 100644 index 00000000000..6fc743d3b4d --- /dev/null +++ b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.scss @@ -0,0 +1,31 @@ +.bulk-selected { + align-items: center; + align-self: flex-end; + display: inline-flex; + font-size: 16px; + gap: 4px; + height: 36px; +} + +.bulk-actions-container { + align-items: flex-end; + display: flex; + gap: 12px; +} + +.bulk-button-wrapper { + display: flex; + flex-direction: column; + + label { + color: var(--fg2); + font-size: 10px; + margin-bottom: 2px; + } + + button { + background-color: var(--bg1); + border: 1px solid var(--lines); + font-size: 12px; + } +} diff --git a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.spec.ts b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.spec.ts new file mode 100644 index 00000000000..ed3aea024fe --- /dev/null +++ b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.spec.ts @@ -0,0 +1,90 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatDialog } from '@angular/material/dialog'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatMenuHarness } from '@angular/material/menu/testing'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; +import { VirtualizationInstance } from 'app/interfaces/virtualization.interface'; +import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; +import { StopOptionsDialogComponent, StopOptionsOperation } from 'app/pages/virtualization/components/all-instances/instance-list/stop-options-dialog/stop-options-dialog.component'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; +import { InstanceListBulkActionsComponent } from './instance-list-bulk-actions.component'; + +describe('InstanceListBulkActionsComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + let menu: MatMenuHarness; + + const checkedInstancesMock = [ + { id: '1', status: 'Running' }, + { id: '2', status: 'Stopped' }, + ] as unknown as VirtualizationInstance[]; + + const createComponent = createComponentFactory({ + component: InstanceListBulkActionsComponent, + imports: [MatMenuModule], + providers: [ + mockProvider(SnackbarService), + mockAuth(), + mockProvider(MatDialog, { + open: jest.fn(() => ({ + afterClosed: jest.fn(() => of(true)), + })), + }), + mockProvider(ErrorHandlerService), + ], + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + checkedInstances: checkedInstancesMock, + }, + }); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + menu = await loader.getHarness(MatMenuHarness); + await menu.open(); + }); + + it('displays the correct count of selected instances', () => { + const selectedCount = spectator.query('.bulk-selected span:first-child'); + expect(selectedCount).toHaveText(String(checkedInstancesMock.length)); + }); + + it('calls onBulkStart when Start All Selected is clicked', async () => { + const startSpy = jest.spyOn(spectator.component, 'onBulkStart'); + + await menu.open(); + await menu.clickItem({ text: 'Start All Selected' }); + + expect(startSpy).toHaveBeenCalled(); + expect(spectator.inject(SnackbarService).success).toHaveBeenCalledWith('Requested action performed for selected Instances'); + }); + + it('opens the Stop Options dialog when Stop All Selected is clicked', async () => { + const matDialog = spectator.inject(MatDialog); + + await menu.open(); + await menu.clickItem({ text: 'Stop All Selected' }); + + expect(matDialog.open).toHaveBeenCalledWith(StopOptionsDialogComponent, { data: StopOptionsOperation.Stop }); + }); + + it('opens the Restart Options dialog when Restart All Selected is clicked', async () => { + const matDialog = spectator.inject(MatDialog); + + await menu.open(); + await menu.clickItem({ text: 'Restart All Selected' }); + + expect(matDialog.open).toHaveBeenCalledWith(StopOptionsDialogComponent, { data: StopOptionsOperation.Restart }); + }); + + it('emits resetBulkSelection after actions', () => { + const resetSpy = jest.spyOn(spectator.component.resetBulkSelection, 'emit'); + + spectator.component.onBulkStart(); + expect(resetSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.ts b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.ts new file mode 100644 index 00000000000..f759a15d836 --- /dev/null +++ b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list-bulk-actions/instance-list-bulk-actions.component.ts @@ -0,0 +1,140 @@ +import { + Component, + ChangeDetectionStrategy, + computed, + input, + output, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + filter, + tap, +} from 'rxjs'; +import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; +import { Role } from 'app/enums/role.enum'; +import { VirtualizationStatus } from 'app/enums/virtualization.enum'; +import { VirtualizationInstance, VirtualizationStopParams } from 'app/interfaces/virtualization.interface'; +import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; +import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; +import { TestDirective } from 'app/modules/test-id/test.directive'; +import { StopOptionsDialogComponent, StopOptionsOperation } from 'app/pages/virtualization/components/all-instances/instance-list/stop-options-dialog/stop-options-dialog.component'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; +import { ApiService } from 'app/services/websocket/api.service'; + +@UntilDestroy() +@Component({ + selector: 'ix-instance-list-bulk-actions', + templateUrl: './instance-list-bulk-actions.component.html', + styleUrls: ['./instance-list-bulk-actions.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + MatButton, + IxIconComponent, + MatMenu, + MatMenuItem, + MatMenuTrigger, + TranslateModule, + RequiresRolesDirective, + TestDirective, + ], +}) + +export class InstanceListBulkActionsComponent { + readonly checkedInstances = input(); + readonly resetBulkSelection = output(); + + protected readonly requiredRoles = [Role.VirtInstanceWrite]; + + readonly bulkActionStartedMessage = this.translate.instant('Requested action performed for selected Instances'); + + protected readonly isBulkStartDisabled = computed(() => { + return this.checkedInstances().every( + (instance) => [VirtualizationStatus.Running].includes(instance.status), + ); + }); + + protected readonly isBulkStopDisabled = computed(() => { + return this.checkedInstances().every( + (instance) => [VirtualizationStatus.Stopped].includes(instance.status), + ); + }); + + protected readonly activeCheckedInstances = computed(() => { + return this.checkedInstances().filter( + (instance) => [VirtualizationStatus.Running].includes(instance.status), + ); + }); + + protected readonly stoppedCheckedInstances = computed(() => { + return this.checkedInstances().filter( + (instance) => [VirtualizationStatus.Stopped].includes(instance.status), + ); + }); + + constructor( + private translate: TranslateService, + private snackbar: SnackbarService, + private api: ApiService, + private errorHandler: ErrorHandlerService, + private matDialog: MatDialog, + ) {} + + onBulkStart(): void { + this.stoppedCheckedInstances().forEach((instance) => this.start(instance.id)); + this.resetBulkSelection.emit(); + this.snackbar.success(this.translate.instant(this.bulkActionStartedMessage)); + } + + onBulkStop(): void { + this.matDialog + .open(StopOptionsDialogComponent, { data: StopOptionsOperation.Stop }) + .afterClosed() + .pipe( + filter(Boolean), + tap((options: VirtualizationStopParams) => { + this.activeCheckedInstances().forEach((instance) => this.stop(instance.id, options)); + this.snackbar.success(this.translate.instant(this.bulkActionStartedMessage)); + this.resetBulkSelection.emit(); + }), + untilDestroyed(this), + ).subscribe(); + } + + onBulkRestart(): void { + this.matDialog + .open(StopOptionsDialogComponent, { data: StopOptionsOperation.Restart }) + .afterClosed() + .pipe( + filter(Boolean), + tap((options: VirtualizationStopParams) => { + this.activeCheckedInstances().forEach((instance) => this.restart(instance.id, options)); + this.snackbar.success(this.translate.instant(this.bulkActionStartedMessage)); + this.resetBulkSelection.emit(); + }), + untilDestroyed(this), + ).subscribe(); + } + + private start(instanceId: string): void { + this.api.job('virt.instance.start', [instanceId]) + .pipe(this.errorHandler.catchError(), untilDestroyed(this)) + .subscribe(); + } + + private stop(instanceId: string, options: VirtualizationStopParams): void { + this.api.job('virt.instance.stop', [instanceId, options]) + .pipe(this.errorHandler.catchError(), untilDestroyed(this)) + .subscribe(); + } + + private restart(instanceId: string, options: VirtualizationStopParams): void { + this.api.job('virt.instance.restart', [instanceId, options]) + .pipe(this.errorHandler.catchError(), untilDestroyed(this)) + .subscribe(); + } +} diff --git a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html index 531d77ed73a..cf6f3589c6a 100644 --- a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html +++ b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html @@ -1,5 +1,16 @@
+
+

{{ 'Instances' | translate }}

+ + @if (checkedInstances?.length > 0) { + + } +
+