Skip to content

Commit

Permalink
NAS-133675: Refactor CloudBackupListComponent to master-detail-view (#…
Browse files Browse the repository at this point in the history
…11429)

* NAS-133675: Refactor CloudBackupListComponent to master-detail-view

* NAS-133675: Refactor CloudBackupListComponent to master-detail-view

* NAS-133675: Refactor CloudBackupListComponent to master-detail-view

* NAS-133675: Refactor CloudBackupListComponent to master-detail-view

* NAS-133675: Skip tests

* NAS-133675: Update

* NAS-133675: Listen for cloudbackup changes

* NAS-133675: Listen for cloudbackup changes

* NAS-133675: Update tests

* NAS-133675: Update tests

(cherry picked from commit fd36ee9)
  • Loading branch information
denysbutenko committed Feb 10, 2025
1 parent 7962fcf commit 8d5d426
Show file tree
Hide file tree
Showing 13 changed files with 560 additions and 448 deletions.
6 changes: 0 additions & 6 deletions src/app/pages/audit/audit.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,3 @@
border-radius: 0;
margin-bottom: 16px;
}

.mobile-hidden {
@media (max-width: $breakpoint-md) {
display: none;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<ix-page-header>
<button
*ixRequiresRoles="requiredRoles"
mat-button
color="primary"
ixTest="add-task"
[ixUiSearch]="searchableElements.elements.add"
(click)="openForm()"
>
{{ 'Add' | translate }}
</button>
</ix-page-header>

<ix-master-detail-view
#masterDetailView="masterDetailViewContext"
[selectedItem]="dataProvider?.expandedRow"
>
<ix-cloud-backup-list
master
[dataProvider]="dataProvider"
[cloudBackups]="cloudBackups()"
[isMobileView]="masterDetailView.isMobileView()"
(toggleShowMobileDetails)="masterDetailView.toggleShowMobileDetails($event)"
></ix-cloud-backup-list>

<ng-container detail-header>
{{ 'Task Details for {task}' | translate: { task: dataProvider?.expandedRow?.description} }}
</ng-container>

<ng-container detail>
@if (dataProvider.expandedRow) {
<ix-cloud-backup-details [backup]="dataProvider.expandedRow"></ix-cloud-backup-details>
}
</ng-container>
</ix-master-detail-view>
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { Spectator, createComponentFactory, mockProvider } from '@ngneat/spectator/jest';
import { provideMockStore } from '@ngrx/store/testing';
import { MockComponents, MockDirective } from 'ng-mocks';
import { of } from 'rxjs';
import { mockApi, mockCall, mockJob } from 'app/core/testing/utils/mock-api.utils';
import { mockAuth } from 'app/core/testing/utils/mock-auth.utils';
import { DetailsHeightDirective } from 'app/directives/details-height/details-height.directive';
import { JobState } from 'app/enums/job-state.enum';
import { AdvancedConfig } from 'app/interfaces/advanced-config.interface';
import { CloudBackup } from 'app/interfaces/cloud-backup.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxIconHarness } from 'app/modules/ix-icon/ix-icon.harness';
import { IxTableHarness } from 'app/modules/ix-table/components/ix-table/ix-table.harness';
import { SortDirection } from 'app/modules/ix-table/enums/sort-direction.enum';
import { selectJobs } from 'app/modules/jobs/store/job.selectors';
import { MasterDetailViewComponent } from 'app/modules/master-detail-view/master-detail-view.component';
import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component';
import { SlideIn } from 'app/modules/slide-ins/slide-in';
import { ApiService } from 'app/modules/websocket/api.service';
import { AllCloudBackupsComponent } from 'app/pages/data-protection/cloud-backup/all-cloud-backups/all-cloud-backups.component';
import { CloudBackupDetailsComponent } from 'app/pages/data-protection/cloud-backup/cloud-backup-details/cloud-backup-details.component';
import { CloudBackupFormComponent } from 'app/pages/data-protection/cloud-backup/cloud-backup-form/cloud-backup-form.component';
import { selectPreferences } from 'app/store/preferences/preferences.selectors';
import { selectAdvancedConfig, selectSystemConfigState } from 'app/store/system-config/system-config.selectors';

describe('AllCloudBackupsComponent', () => {
let spectator: Spectator<AllCloudBackupsComponent>;
let loader: HarnessLoader;
let table: IxTableHarness;

const cloudBackups = [
{
id: 1,
description: 'UA',
path: '/mnt/nmnmn',
pre_script: 'your_pre_script',
snapshot: false,
enabled: false,
job: {
state: JobState.Finished,
time_finished: {
$date: new Date().getTime() - 50000,
},
},
},
{
id: 2,
description: 'UAH',
path: '/mnt/hahah',
pre_script: 'your_pre_script',
snapshot: false,
enabled: true,
job: {
state: JobState.Finished,
time_finished: {
$date: new Date().getTime() - 50000,
},
},
},
] as CloudBackup[];

const createComponent = createComponentFactory({
component: AllCloudBackupsComponent,
imports: [
MockComponents(
PageHeaderComponent,
CloudBackupDetailsComponent,
),
MockDirective(DetailsHeightDirective),
],
providers: [
mockAuth(),
mockApi([
mockCall('cloud_backup.query', cloudBackups),
mockCall('cloud_backup.delete'),
mockCall('cloud_backup.update'),
mockJob('cloud_backup.sync'),
]),
mockProvider(DialogService, {
confirm: jest.fn(() => of(true)),
}),
mockProvider(SlideIn, {
open: jest.fn(() => of({
response: true,
})),
}),
provideMockStore({
selectors: [
{
selector: selectSystemConfigState,
value: {},
},
{
selector: selectPreferences,
value: {},
},
{
selector: selectJobs,
value: [{
state: JobState.Finished,
time_finished: {
$date: new Date().getTime() - 50000,
},
}],
},
{
selector: selectAdvancedConfig,
value: {
consolemenu: true,
serialconsole: true,
serialport: 'ttyS0',
serialspeed: '9600',
motd: 'Welcome back, commander',
} as AdvancedConfig,
},
],
}),
],
});

beforeEach(async () => {
spectator = createComponent();
loader = TestbedHarnessEnvironment.loader(spectator.fixture);
table = await loader.getHarness(IxTableHarness);
});

it('checks used components on page', () => {
expect(spectator.query(PageHeaderComponent)).toExist();
expect(spectator.query(MasterDetailViewComponent)).toExist();
});

it('shows form to create new Cloud Backup when Add button is pressed', async () => {
const addButton = await loader.getHarness(MatButtonHarness.with({ text: 'Add' }));
await addButton.click();

expect(spectator.inject(SlideIn).open).toHaveBeenCalledWith(
CloudBackupFormComponent,
{ wide: true },
);
});

describe('cloud backup list', () => {
it('should show table rows', async () => {
const expectedRows = [
['Name', 'Enabled', 'Snapshot', 'State', 'Last Run', ''],
['UA', '', 'No', 'FINISHED', '1 min. ago', ''],
['UAH', '', 'No', 'FINISHED', '1 min. ago', ''],
];
const cells = await table.getCellTexts();
expect(cells).toEqual(expectedRows);
});

it('sets the default sort for dataProvider', () => {
spectator.component.dataProvider.load();

expect(spectator.component.dataProvider.sorting).toEqual({
active: 1,
direction: SortDirection.Asc,
propertyName: 'description',
});
});

it('shows form to edit an existing Cloud Backup when Edit button is pressed', async () => {
const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'edit' }), 1, 5);
await editButton.click();

expect(spectator.inject(SlideIn).open).toHaveBeenCalledWith(
CloudBackupFormComponent,
{
wide: true,
data: cloudBackups[0],
},
);
});

it('shows confirmation dialog when Run Now button is pressed', async () => {
const runNowButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-play-circle' }), 1, 5);
await runNowButton.click();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({
title: 'Run Now',
message: 'Run «UA» Cloud Backup now?',
hideCheckbox: true,
});

expect(spectator.inject(ApiService).job).toHaveBeenCalledWith('cloud_backup.sync', [1]);
expect(spectator.component.dataProvider.expandedRow).toEqual({ ...cloudBackups[0] });
});

it('deletes a Cloud Backup with confirmation when Delete button is pressed', async () => {
const deleteIcon = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-delete' }), 1, 5);
await deleteIcon.click();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({
title: 'Confirmation',
message: 'Delete Cloud Backup <b>"UA"</b>?',
buttonColor: 'warn',
buttonText: 'Delete',
});

expect(spectator.inject(ApiService).call).toHaveBeenCalledWith('cloud_backup.delete', [1]);
});

it('updates Cloud Backup Enabled status once mat-toggle is updated', async () => {
const toggle = await table.getHarnessInCell(MatSlideToggleHarness, 1, 1);

expect(await toggle.isChecked()).toBe(false);

await toggle.check();

expect(spectator.inject(ApiService).call).toHaveBeenCalledWith(
'cloud_backup.update',
[1, { enabled: true }],
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
Component, ChangeDetectionStrategy, signal, OnInit,
ChangeDetectorRef,
} from '@angular/core';
import { MatButton } from '@angular/material/button';
import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateModule } from '@ngx-translate/core';
import { filter, tap } from 'rxjs';
import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive';
import { UiSearchDirective } from 'app/directives/ui-search.directive';
import { Role } from 'app/enums/role.enum';
import { CloudBackup } from 'app/interfaces/cloud-backup.interface';
import { AsyncDataProvider } from 'app/modules/ix-table/classes/async-data-provider/async-data-provider';
import { SortDirection } from 'app/modules/ix-table/enums/sort-direction.enum';
import { MasterDetailViewComponent } from 'app/modules/master-detail-view/master-detail-view.component';
import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component';
import { SlideIn } from 'app/modules/slide-ins/slide-in';
import { ApiService } from 'app/modules/websocket/api.service';
import { CloudBackupDetailsComponent } from 'app/pages/data-protection/cloud-backup/cloud-backup-details/cloud-backup-details.component';
import { CloudBackupFormComponent } from 'app/pages/data-protection/cloud-backup/cloud-backup-form/cloud-backup-form.component';
import { CloudBackupListComponent } from 'app/pages/data-protection/cloud-backup/cloud-backup-list/cloud-backup-list.component';
import { cloudBackupListElements } from 'app/pages/data-protection/cloud-backup/cloud-backup-list/cloud-backup-list.elements';

@UntilDestroy()
@Component({
selector: 'ix-all-cloud-backups',
templateUrl: './all-cloud-backups.component.html',
styleUrls: ['./all-cloud-backups.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
MasterDetailViewComponent,
CloudBackupListComponent,
CloudBackupDetailsComponent,
PageHeaderComponent,
TranslateModule,
UiSearchDirective,
RequiresRolesDirective,
MatButton,
],
})
export class AllCloudBackupsComponent implements OnInit {
dataProvider: AsyncDataProvider<CloudBackup>;
protected readonly cloudBackups = signal<CloudBackup[]>([]);
protected readonly searchableElements = cloudBackupListElements;
readonly requiredRoles = [Role.CloudBackupWrite];

constructor(
private api: ApiService,
private slideIn: SlideIn,
private route: ActivatedRoute,
private cdr: ChangeDetectorRef,
private router: Router,
) {
this.router.events
.pipe(filter((event) => event instanceof NavigationStart), untilDestroyed(this))
.subscribe(() => {
if (this.router.getCurrentNavigation()?.extras?.state?.hideMobileDetails) {
this.dataProvider.expandedRow = null;
this.cdr.markForCheck();
}
});
}

ngOnInit(): void {
this.route.fragment.pipe(
tap((id) => this.loadCloudBackups(id || undefined)),
untilDestroyed(this),
).subscribe();
}

openForm(row?: CloudBackup): void {
this.slideIn.open(CloudBackupFormComponent, { data: row, wide: true })
.pipe(
filter((response) => !!response.response),
untilDestroyed(this),
).subscribe(() => this.dataProvider.load());
}

private loadCloudBackups(id?: string): void {
const cloudBackups$ = this.api.call('cloud_backup.query').pipe(
tap((cloudBackups) => {
this.cloudBackups.set(cloudBackups);

const selectedBackup = id
? cloudBackups.find((cloudBackup) => cloudBackup.id.toString() === id)
: cloudBackups.find((cloudBackup) => cloudBackup.id === this.dataProvider?.expandedRow?.id);

if (selectedBackup) {
this.dataProvider.expandedRow = selectedBackup;
} else if (cloudBackups.length) {
const [firstBackup] = cloudBackups;
this.dataProvider.expandedRow = firstBackup;
}
this.cdr.markForCheck();
}),
);

this.dataProvider = new AsyncDataProvider<CloudBackup>(cloudBackups$);
this.dataProvider.setSorting({
active: 1,
direction: SortDirection.Asc,
propertyName: 'description',
});
this.dataProvider.load();
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,3 @@
<div class="header">
<h2 class="title">
<div class="mobile-prefix">
<ix-mobile-back-button
(close)="onCloseMobileDetails()"
></ix-mobile-back-button>
{{ 'Task Details for {task}' | translate: { task: backup().description } }}
</div>

<span class="prefix">
{{ 'Task Details for {task}' | translate: { task: backup().description } }}
</span>
</h2>
</div>

<div class="cards">
<div class="scroll-window">
<ix-cloud-backup-schedule [backup]="backup()"></ix-cloud-backup-schedule>
Expand Down
Loading

0 comments on commit 8d5d426

Please sign in to comment.