Skip to content

Commit

Permalink
NAS-132583 / 25.04 / Add mass actions to containers (#11072)
Browse files Browse the repository at this point in the history
* NAS-132583: Add mass actions to containers

* NAS-132583: PR Update

* NAS-132583: PR Update
  • Loading branch information
AlexKarpov98 authored Nov 25, 2024
1 parent f448132 commit d4af2de
Show file tree
Hide file tree
Showing 96 changed files with 605 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ <h2>{{ 'Applications' | translate }}</h2>
mat-button
ixTest="bulk-actions-menu"
[matMenuTriggerFor]="menu"
[disabled]="!hasCheckedApps"
>
{{ 'Select action' | translate }}
<ix-icon name="mdi-menu-down" class="menu-caret"></ix-icon>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@
}

.table-container {
background: var(--bg2);
box-sizing: border-box;
display: flex;
flex: 1;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<div class="bulk-actions-container">
<div class="bulk-selected">
<span>{{ checkedInstances().length }}</span>
<span>{{ 'Selected' | translate }}</span>
</div>

<div class="bulk-button-wrapper">
<label>{{ 'Bulk Actions' | translate }}</label>
<button
*ixRequiresRoles="requiredRoles"
mat-button
ixTest="bulk-actions-menu"
[matMenuTriggerFor]="menu"
>
{{ 'Select action' | translate }}
<ix-icon name="mdi-menu-down" class="menu-caret"></ix-icon>
</button>
</div>

<mat-menu #menu="matMenu">
<button
*ixRequiresRoles="requiredRoles"
mat-menu-item
ixTest="start-selected"
[disabled]="isBulkStartDisabled()"
(click)="onBulkStart()"
>
<span>{{ 'Start All Selected' | translate }}</span>
</button>
<button
*ixRequiresRoles="requiredRoles"
mat-menu-item
ixTest="stop-selected"
[disabled]="isBulkStopDisabled()"
(click)="onBulkStop()"
>
<span>{{ 'Stop All Selected' | translate }}</span>
</button>
<button
*ixRequiresRoles="requiredRoles"
mat-menu-item
ixTest="restart-selected"
[disabled]="isBulkStopDisabled()"
(click)="onBulkRestart()"
>
<span>{{ 'Restart All Selected' | translate }}</span>
</button>
</mat-menu>
</div>
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<InstanceListBulkActionsComponent>;
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();
});
});
Original file line number Diff line number Diff line change
@@ -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<VirtualizationInstance[]>();
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();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
<div class="container">
<div class="table-container">
<div class="table-header">
<h2>{{ 'Instances' | translate }}</h2>

@if (checkedInstances?.length > 0) {
<ix-instance-list-bulk-actions
[checkedInstances]="checkedInstances"
(resetBulkSelection)="resetSelection()"
></ix-instance-list-bulk-actions>
}
</div>

<div class="item-search">
@if (!showMobileDetails()) {
<ix-fake-progress-bar
Expand All @@ -23,8 +34,8 @@
<mat-checkbox
color="primary"
ixTest="select-all-app"
[checked]="isAllSelected()"
[indeterminate]="!isAllSelected() && !!selection.selected.length"
[checked]="isAllSelected"
[indeterminate]="!isAllSelected && !!selection.selected.length"
[disabled]="filteredInstances()?.length === 0"
(change)="toggleAllChecked($event.checked)"
></mat-checkbox>
Expand All @@ -46,6 +57,7 @@
[class.selected]="selectedInstance()?.id === instance.id"
[selected]="selection.isSelected(instance.id)"
(click)="navigateToDetails(instance)"
(selectionChange)="selection.toggle(instance.id)"
(keydown.enter)="navigateToDetails(instance)"
></ix-instance-row>
}
Expand Down
Loading

0 comments on commit d4af2de

Please sign in to comment.