- tasks.mgr.dashboard.test_auth
- tasks.mgr.dashboard.test_cephfs
- tasks.mgr.dashboard.test_cluster_configuration
+ - tasks.mgr.dashboard.test_cluster
- tasks.mgr.dashboard.test_crush_rule
- tasks.mgr.dashboard.test_erasure_code_profile
- tasks.mgr.dashboard.test_ganesha
@raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_CREATE])
@handle_orchestrator_error('host')
- @host_task('create', {'hostname': '{hostname}'})
+ @host_task('add', {'hostname': '{hostname}'})
@EndpointDoc('',
parameters={
'hostname': (str, 'Hostname'),
+++ /dev/null
-import { PageHelper } from '../page-helper.po';
-import { NotificationSidebarPageHelper } from '../ui/notification.po';
-
-export class CreateClusterWelcomePageHelper extends PageHelper {
- pages = {
- index: { url: '#/create-cluster', id: 'cd-create-cluster' }
- };
-
- createCluster() {
- cy.get('cd-create-cluster').should('contain.text', 'Welcome to Ceph');
- cy.get('[name=create-cluster]').click();
- }
-
- doSkip() {
- cy.get('[name=skip-cluster-creation]').click();
-
- cy.get('cd-dashboard').should('exist');
- const notification = new NotificationSidebarPageHelper();
- notification.open();
- notification.getNotifications().should('contain', 'Cluster creation skipped by user');
- }
-}
+++ /dev/null
-import { PageHelper } from '../page-helper.po';
-
-export class CreateClusterReviewPageHelper extends PageHelper {
- pages = {
- index: { url: '#/create-cluster', id: 'cd-create-cluster-review' }
- };
-
- checkDefaultHostName() {
- this.getTableCell(1, 'ceph-node-00.cephlab.com').should('exist');
- }
-}
--- /dev/null
+import { PageHelper } from '../page-helper.po';
+import { NotificationSidebarPageHelper } from '../ui/notification.po';
+
+const pages = {
+ index: { url: '#/expand-cluster', id: 'cd-create-cluster' }
+};
+
+export class CreateClusterWizardHelper extends PageHelper {
+ pages = pages;
+ columnIndex = {
+ hostname: 1,
+ labels: 2,
+ status: 3
+ };
+
+ createCluster() {
+ cy.get('cd-create-cluster').should('contain.text', 'Please expand your cluster first');
+ cy.get('[name=expand-cluster]').click();
+ }
+
+ doSkip() {
+ cy.get('[name=skip-cluster-creation]').click();
+ cy.contains('cd-modal button', 'Continue').click();
+
+ cy.get('cd-dashboard').should('exist');
+ const notification = new NotificationSidebarPageHelper();
+ notification.open();
+ notification.getNotifications().should('contain', 'Cluster expansion skipped by user');
+ }
+
+ check_for_host() {
+ this.getTableCount('total').should('not.be.eq', 0);
+ }
+
+ clickHostTab(hostname: string, tabName: string) {
+ this.getExpandCollapseElement(hostname).click();
+ cy.get('cd-host-details').within(() => {
+ this.getTab(tabName).click();
+ });
+ }
+
+ add(hostname: string, exist?: boolean, maintenance?: boolean) {
+ cy.get('.btn.btn-accent').first().click({ force: true });
+
+ cy.get('cd-modal').should('exist');
+ cy.get('cd-modal').within(() => {
+ cy.get('#hostname').type(hostname);
+ if (maintenance) {
+ cy.get('label[for=maintenance]').click();
+ }
+ if (exist) {
+ cy.get('#hostname').should('have.class', 'ng-invalid');
+ }
+ cy.get('cd-submit-button').click();
+ });
+ // back to host list
+ cy.get(`${this.pages.index.id}`);
+ }
+
+ checkExist(hostname: string, exist: boolean) {
+ this.clearTableSearchInput();
+ this.getTableCell(this.columnIndex.hostname, hostname).should(($elements) => {
+ const hosts = $elements.map((_, el) => el.textContent).get();
+ if (exist) {
+ expect(hosts).to.include(hostname);
+ } else {
+ expect(hosts).to.not.include(hostname);
+ }
+ });
+ }
+
+ delete(hostname: string) {
+ super.delete(hostname, this.columnIndex.hostname);
+ }
+
+ // Add or remove labels on a host, then verify labels in the table
+ editLabels(hostname: string, labels: string[], add: boolean) {
+ this.getTableCell(this.columnIndex.hostname, hostname).click();
+ this.clickActionButton('edit');
+
+ // add or remove label badges
+ if (add) {
+ cy.get('cd-modal').find('.select-menu-edit').click();
+ for (const label of labels) {
+ cy.contains('cd-modal .badge', new RegExp(`^${label}$`)).should('not.exist');
+ cy.get('.popover-body input').type(`${label}{enter}`);
+ }
+ } else {
+ for (const label of labels) {
+ cy.contains('cd-modal .badge', new RegExp(`^${label}$`))
+ .find('.badge-remove')
+ .click();
+ }
+ }
+ cy.get('cd-modal cd-submit-button').click();
+
+ // Verify labels are added or removed from Labels column
+ // First find row with hostname, then find labels in the row
+ this.getTableCell(this.columnIndex.hostname, hostname)
+ .parent()
+ .find(`datatable-body-cell:nth-child(${this.columnIndex.labels}) .badge`)
+ .should(($ele) => {
+ const newLabels = $ele.toArray().map((v) => v.innerText);
+ for (const label of labels) {
+ if (add) {
+ expect(newLabels).to.include(label);
+ } else {
+ expect(newLabels).to.not.include(label);
+ }
+ }
+ });
+ }
+}
const pages = {
index: { url: '#/hosts', id: 'cd-hosts' },
- create: { url: '#/hosts/create', id: 'cd-host-form' }
+ add: { url: '#/hosts/(modal:add)', id: 'cd-host-form' }
};
export class HostsPageHelper extends PageHelper {
});
}
- @PageHelper.restrictTo(pages.create.url)
+ @PageHelper.restrictTo(pages.add.url)
add(hostname: string, exist?: boolean, maintenance?: boolean) {
- cy.get(`${this.pages.create.id}`).within(() => {
+ cy.get(`${this.pages.add.id}`).within(() => {
cy.get('#hostname').type(hostname);
if (maintenance) {
cy.get('label[for=maintenance]').click();
}
+ if (exist) {
+ cy.get('#hostname').should('have.class', 'ng-invalid');
+ }
cy.get('cd-submit-button').click();
});
- if (exist) {
- cy.get('#hostname').should('have.class', 'ng-invalid');
- } else {
- // back to host list
- cy.get(`${this.pages.index.id}`);
- }
+ // back to host list
+ cy.get(`${this.pages.index.id}`);
}
@PageHelper.restrictTo(pages.index.url)
it('should not add an exsiting host', function () {
const hostname = Cypress._.sample(this.hosts).name;
- hosts.navigateTo('create');
+ hosts.navigateTo('add');
hosts.add(hostname, true);
});
hosts.delete(host);
// add it back
- hosts.navigateTo('create');
+ hosts.navigateTo('add');
hosts.add(host);
hosts.checkExist(host, true);
});
+++ /dev/null
-import { CreateClusterWelcomePageHelper } from '../cluster/cluster-welcome-page.po';
-
-describe('Create cluster page', () => {
- const createCluster = new CreateClusterWelcomePageHelper();
-
- beforeEach(() => {
- cy.login();
- Cypress.Cookies.preserveOnce('token');
- createCluster.navigateTo();
- });
-
- it('should fail to create cluster', () => {
- createCluster.createCluster();
- });
-
- it('should skip to dashboard landing page', () => {
- createCluster.doSkip();
- });
-});
--- /dev/null
+import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po';
+
+describe('Create cluster page', () => {
+ const createCluster = new CreateClusterWizardHelper();
+
+ beforeEach(() => {
+ cy.login();
+ Cypress.Cookies.preserveOnce('token');
+ createCluster.navigateTo();
+ });
+
+ it('should fail to create cluster', () => {
+ createCluster.createCluster();
+ });
+
+ it('should skip to dashboard landing page', () => {
+ createCluster.doSkip();
+ });
+});
'ceph-node-02.cephlab.com'
];
const addHost = (hostname: string, exist?: boolean, maintenance?: boolean) => {
- hosts.navigateTo('create');
+ hosts.navigateTo('add');
hosts.add(hostname, exist, maintenance);
hosts.checkExist(hostname, true);
};
});
it('should not add an existing host', function () {
- hosts.navigateTo('create');
+ hosts.navigateTo('add');
hosts.add(hostnames[0], true);
});
--- /dev/null
+import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po';
+
+describe('Create cluster add host page', () => {
+ const createCluster = new CreateClusterWizardHelper();
+ const hostnames = [
+ 'ceph-node-00.cephlab.com',
+ 'ceph-node-01.cephlab.com',
+ 'ceph-node-02.cephlab.com'
+ ];
+ const addHost = (hostname: string, exist?: boolean) => {
+ createCluster.add(hostname, exist, true);
+ createCluster.checkExist(hostname, true);
+ };
+
+ beforeEach(() => {
+ cy.login();
+ Cypress.Cookies.preserveOnce('token');
+ createCluster.navigateTo();
+ createCluster.createCluster();
+ });
+
+ it('should check if nav-link and title contains Add Hosts', () => {
+ cy.get('.nav-link').should('contain.text', 'Add Hosts');
+
+ cy.get('.title').should('contain.text', 'Add Hosts');
+ });
+
+ it('should check existing host and add new hosts into maintenance mode', () => {
+ createCluster.checkExist(hostnames[0], true);
+
+ addHost(hostnames[1], false);
+ addHost(hostnames[2], false);
+ });
+
+ it('should not add an existing host', () => {
+ createCluster.add(hostnames[0], true);
+ });
+
+ it('should edit host labels', () => {
+ const labels = ['foo', 'bar'];
+ createCluster.editLabels(hostnames[0], labels, true);
+ createCluster.editLabels(hostnames[0], labels, false);
+ });
+
+ it('should delete a host', () => {
+ createCluster.delete(hostnames[1]);
+ });
+});
--- /dev/null
+import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po';
+
+describe('Create Cluster Review page', () => {
+ const createCluster = new CreateClusterWizardHelper();
+
+ beforeEach(() => {
+ cy.login();
+ Cypress.Cookies.preserveOnce('token');
+ createCluster.navigateTo();
+ createCluster.createCluster();
+
+ cy.get('button[aria-label="Next"]').click();
+ });
+
+ describe('navigation link and title test', () => {
+ it('should check if nav-link and title contains Review', () => {
+ cy.get('.nav-link').should('contain.text', 'Review');
+
+ cy.get('.title').should('contain.text', 'Review');
+ });
+ });
+
+ describe('fields check', () => {
+ it('should check cluster resources table is present', () => {
+ // check for table header 'Cluster Resources'
+ createCluster.getLegends().its(0).should('have.text', 'Cluster Resources');
+
+ // check for fields in table
+ createCluster.getStatusTables().should('contain.text', 'Hosts');
+ });
+
+ it('should check Hosts by Label and Host Details tables are present', () => {
+ // check for there to be two tables
+ createCluster.getDataTables().should('have.length', 2);
+
+ // check for table header 'Hosts by Label'
+ createCluster.getLegends().its(1).should('have.text', 'Hosts by Label');
+
+ // check for table header 'Host Details'
+ createCluster.getLegends().its(2).should('have.text', 'Host Details');
+
+ // verify correct columns on Hosts by Label table
+ createCluster.getDataTableHeaders(0).contains('Label');
+
+ createCluster.getDataTableHeaders(0).contains('Number of Hosts');
+
+ // verify correct columns on Host Details table
+ createCluster.getDataTableHeaders(1).contains('Host Name');
+
+ createCluster.getDataTableHeaders(1).contains('Labels');
+ });
+
+ it('should check hosts count and default host name are present', () => {
+ createCluster.getStatusTables().contains(2);
+
+ createCluster.check_for_host();
+ });
+ });
+});
--- /dev/null
+import { CreateClusterWizardHelper } from 'cypress/integration/cluster/create-cluster.po';
+import { HostsPageHelper } from 'cypress/integration/cluster/hosts.po';
+
+describe('when cluster creation is completed', () => {
+ const createCluster = new CreateClusterWizardHelper();
+
+ beforeEach(() => {
+ cy.login();
+ Cypress.Cookies.preserveOnce('token');
+ });
+
+ it('should redirect to dashboard landing page after cluster creation', () => {
+ createCluster.navigateTo();
+ createCluster.createCluster();
+
+ cy.get('button[aria-label="Next"]').click();
+ cy.get('button[aria-label="Next"]').click();
+
+ cy.get('cd-dashboard').should('exist');
+ });
+
+ describe('Hosts page', () => {
+ const hosts = new HostsPageHelper();
+ const hostnames = ['ceph-node-00.cephlab.com', 'ceph-node-02.cephlab.com'];
+
+ beforeEach(() => {
+ hosts.navigateTo();
+ });
+ it('should have already exited from maintenance', () => {
+ for (let host = 0; host < hostnames.length; host++) {
+ cy.get('datatable-row-wrapper').should('not.have.text', 'maintenance');
+ }
+ });
+
+ it('should display inventory', () => {
+ hosts.clickHostTab(hostnames[1], 'Physical Disks');
+ cy.get('cd-host-details').within(() => {
+ hosts.getTableCount('total').should('be.gte', 0);
+ });
+ });
+
+ it('should display daemons', () => {
+ hosts.clickHostTab(hostnames[1], 'Daemons');
+ cy.get('cd-host-details').within(() => {
+ hosts.getTableCount('total').should('be.gte', 0);
+ });
+ });
+ });
+});
+++ /dev/null
-import { CreateClusterWelcomePageHelper } from 'cypress/integration/cluster/cluster-welcome-page.po';
-import { CreateClusterReviewPageHelper } from 'cypress/integration/cluster/create-cluster-review.po';
-
-describe('Create Cluster Review page', () => {
- const reviewPage = new CreateClusterReviewPageHelper();
- const createCluster = new CreateClusterWelcomePageHelper();
-
- beforeEach(() => {
- cy.login();
- Cypress.Cookies.preserveOnce('token');
- createCluster.navigateTo();
- createCluster.createCluster();
-
- cy.get('button[aria-label="Next"]').click();
- });
-
- describe('navigation link and title test', () => {
- it('should check if nav-link and title contains Review', () => {
- cy.get('.nav-link').should('contain.text', 'Review');
-
- cy.get('.title').should('contain.text', 'Review');
- });
- });
-
- describe('fields check', () => {
- it('should check cluster resources table is present', () => {
- // check for table header 'Status'
- reviewPage.getLegends().its(0).should('have.text', 'Cluster Resources');
-
- // check for fields in table
- reviewPage.getStatusTables().should('contain.text', 'Hosts');
- });
-
- it('should check Hosts Per Label and Host Details tables are present', () => {
- // check for there to be two tables
- reviewPage.getDataTables().should('have.length', 2);
-
- // check for table header 'Hosts Per Label'
- reviewPage.getLegends().its(1).should('have.text', 'Hosts Per Label');
-
- // check for table header 'Host Details'
- reviewPage.getLegends().its(2).should('have.text', 'Host Details');
-
- // verify correct columns on Hosts Per Label table
- reviewPage.getDataTableHeaders(0).contains('Label');
-
- reviewPage.getDataTableHeaders(0).contains('Number of Hosts');
-
- // verify correct columns on Host Details table
- reviewPage.getDataTableHeaders(1).contains('Host Name');
-
- reviewPage.getDataTableHeaders(1).contains('Labels');
- });
-
- it('should check hosts count and default host name are present', () => {
- reviewPage.getStatusTables().should('contain.text', '1');
-
- reviewPage.checkDefaultHostName();
- });
- });
-});
// Cluster
{
- path: 'create-cluster',
+ path: 'expand-cluster',
component: CreateClusterComponent,
canActivate: [ModuleStatusGuardService],
data: {
redirectTo: 'dashboard',
backend: 'cephadm'
},
- breadcrumbs: 'Create Cluster'
+ breadcrumbs: 'Expand Cluster'
}
},
{
path: 'hosts',
+ component: HostsComponent,
data: { breadcrumbs: 'Cluster/Hosts' },
children: [
- { path: '', component: HostsComponent },
{
- path: URLVerbs.CREATE,
+ path: URLVerbs.ADD,
component: HostFormComponent,
- data: { breadcrumbs: ActionLabels.CREATE }
+ outlet: 'modal'
}
]
},
import { TreeModule } from '@circlon/angular-tree-component';
import {
+ NgbActiveModal,
NgbDatepickerModule,
NgbDropdownModule,
NgbNavModule,
OsdCreationPreviewModalComponent,
RulesListComponent,
ActiveAlertListComponent,
- HostFormComponent,
ServiceDetailsComponent,
ServiceDaemonListComponent,
TelemetryComponent,
OsdFlagsIndivModalComponent,
PlacementPipe,
CreateClusterComponent
- ]
+ ],
+ providers: [NgbActiveModal]
})
export class ClusterModule {}
-<div class="container h-75">
+<div class="container h-75"
+ *ngIf="!startClusterCreation">
<div class="row h-100 justify-content-center align-items-center">
<div class="blank-page">
<!-- htmllint img-req-src="false" -->
<div class="m-4">
<h4 class="text-center"
- i18n>Please proceed to complete the cluster creation</h4>
- <div class="offset-md-3">
- <button class="btn btn-accent m-3"
- name="create-cluster"
- [routerLink]="'/dashboard'"
+ i18n>Please expand your cluster first</h4>
+ <div class="offset-md-2">
+ <button class="btn btn-accent m-2"
+ name="expand-cluster"
(click)="createCluster()"
- i18n>Create Cluster</button>
+ i18n>Expand Cluster</button>
<button class="btn btn-light"
name="skip-cluster-creation"
- [routerLink]="'/dashboard'"
(click)="skipClusterCreation()"
i18n>Skip</button>
</div>
</div>
</div>
</div>
+
+<div class="card"
+ *ngIf="startClusterCreation">
+ <div class="card-header"
+ i18n>Expand Cluster</div>
+ <div class="container-fluid">
+ <cd-wizard [stepsTitle]="stepTitles"></cd-wizard>
+ <div class="card-body vertical-line">
+ <ng-container [ngSwitch]="currentStep?.stepIndex">
+ <div *ngSwitchCase="'1'"
+ class="ml-5">
+ <h4 class="title"
+ i18n>Add Hosts</h4>
+ <br>
+ <cd-hosts [clusterCreation]="true"></cd-hosts>
+ </div>
+ <div *ngSwitchCase="'2'"
+ class="ml-5">
+ <h4 class="title"
+ i18n>Review</h4>
+ <br>
+ <p>To be implemented</p>
+ </div>
+ </ng-container>
+ </div>
+ </div>
+ <div class="card-footer">
+ <button class="btn btn-accent m-2 float-right"
+ (click)="onNextStep()"
+ aria-label="Next"
+ i18n>{{ showSubmitButtonLabel() }}</button>
+ <cd-back-button class="m-2 float-right"
+ aria-label="Close"
+ (backAction)="onPreviousStep()"
+ [name]="showCancelButtonLabel()"></cd-back-button>
+ </div>
+</div>
+
+<ng-template #skipConfirmTpl>
+ <span i18n>You are about to skip the cluster expansion process.
+ You’ll need to <strong>navigate through the menu to add hosts and services.</strong></span>
+
+ <div class="mt-4"
+ i18n>Are you sure you want to continue?</div>
+</ng-template>
+@use './src/styles/vendor/variables' as vv;
+
+.container-fluid {
+ align-items: flex-start;
+ display: flex;
+ padding-left: 0;
+ width: 100%;
+}
+
+.card-body {
+ max-width: 85%;
+}
+
+.vertical-line {
+ border-left: 1px solid vv.$gray-400;
+}
+
+cd-wizard {
+ width: 15%;
+}
+
+cd-hosts {
+ ::ng-deep .nav {
+ display: none;
+ }
+}
import { ToastrModule } from 'ngx-toastr';
-import { ClusterService } from '~/app/shared/api/cluster.service';
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CoreModule } from '~/app/core/core.module';
+import { HostService } from '~/app/shared/api/host.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { AppConstants } from '~/app/shared/constants/app.constants';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed } from '~/testing/unit-test-helper';
import { CreateClusterComponent } from './create-cluster.component';
describe('CreateClusterComponent', () => {
let component: CreateClusterComponent;
let fixture: ComponentFixture<CreateClusterComponent>;
- let clusterService: ClusterService;
+ let wizardStepService: WizardStepsService;
+ let hostService: HostService;
+ let modalServiceShowSpy: jasmine.Spy;
+ const projectConstants: typeof AppConstants = AppConstants;
configureTestBed({
- declarations: [CreateClusterComponent],
- imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot(), SharedModule]
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ SharedModule,
+ CoreModule,
+ CephModule
+ ]
});
beforeEach(() => {
fixture = TestBed.createComponent(CreateClusterComponent);
component = fixture.componentInstance;
- clusterService = TestBed.inject(ClusterService);
+ wizardStepService = TestBed.inject(WizardStepsService);
+ hostService = TestBed.inject(HostService);
+ modalServiceShowSpy = spyOn(TestBed.inject(ModalService), 'show').and.returnValue({
+ // mock the close function, it might be called if there are async tests.
+ close: jest.fn()
+ });
fixture.detectChanges();
});
expect(component).toBeTruthy();
});
- it('should have the heading "Welcome to Ceph Dashboard"', () => {
+ it('should have project name as heading in welcome screen', () => {
const heading = fixture.debugElement.query(By.css('h3')).nativeElement;
- expect(heading.innerHTML).toBe('Welcome to Ceph Dashboard');
+ expect(heading.innerHTML).toBe(`Welcome to ${projectConstants.projectName}`);
});
- it('should call updateStatus when cluster creation is skipped', () => {
- const clusterServiceSpy = spyOn(clusterService, 'updateStatus').and.callThrough();
- expect(clusterServiceSpy).not.toHaveBeenCalled();
+ it('should show confirmation modal when cluster creation is skipped', () => {
component.skipClusterCreation();
- expect(clusterServiceSpy).toHaveBeenCalledTimes(1);
+ expect(modalServiceShowSpy.calls.any()).toBeTruthy();
+ expect(modalServiceShowSpy.calls.first().args[0]).toBe(ConfirmationModalComponent);
+ });
+
+ it('should show the wizard when cluster creation is started', () => {
+ component.createCluster();
+ fixture.detectChanges();
+ const nativeEl = fixture.debugElement.nativeElement;
+ expect(nativeEl.querySelector('cd-wizard')).not.toBe(null);
+ });
+
+ it('should have title Add Hosts', () => {
+ component.createCluster();
+ fixture.detectChanges();
+ const heading = fixture.debugElement.query(By.css('.title')).nativeElement;
+ expect(heading.innerHTML).toBe('Add Hosts');
+ });
+
+ it('should show the host list when cluster creation as first step', () => {
+ component.createCluster();
+ fixture.detectChanges();
+ const nativeEl = fixture.debugElement.nativeElement;
+ expect(nativeEl.querySelector('cd-hosts')).not.toBe(null);
+ });
+
+ it('should move to next step and show the second page', () => {
+ const wizardStepServiceSpy = spyOn(wizardStepService, 'moveToNextStep').and.callThrough();
+ const hostServiceSpy = spyOn(hostService, 'list').and.callThrough();
+ component.createCluster();
+ fixture.detectChanges();
+ component.onNextStep();
+ fixture.detectChanges();
+ const heading = fixture.debugElement.query(By.css('.title')).nativeElement;
+ expect(wizardStepServiceSpy).toHaveBeenCalledTimes(1);
+ expect(hostServiceSpy).toBeCalledTimes(1);
+ expect(heading.innerHTML).toBe('Review');
+ });
+
+ it('should show the button labels correctly', () => {
+ component.createCluster();
+ fixture.detectChanges();
+ let submitBtnLabel = component.showSubmitButtonLabel();
+ expect(submitBtnLabel).toEqual('Next');
+ let cancelBtnLabel = component.showCancelButtonLabel();
+ expect(cancelBtnLabel).toEqual('Cancel');
+
+ // Last page of the wizard
+ component.onNextStep();
+ fixture.detectChanges();
+ submitBtnLabel = component.showSubmitButtonLabel();
+ expect(submitBtnLabel).toEqual('Expand Cluster');
+ cancelBtnLabel = component.showCancelButtonLabel();
+ expect(cancelBtnLabel).toEqual('Back');
});
});
-import { Component } from '@angular/core';
+import { Component, OnDestroy, TemplateRef, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { forkJoin, Subscription } from 'rxjs';
+import { finalize } from 'rxjs/operators';
import { ClusterService } from '~/app/shared/api/cluster.service';
-import { AppConstants } from '~/app/shared/constants/app.constants';
+import { HostService } from '~/app/shared/api/host.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { ActionLabelsI18n, AppConstants } from '~/app/shared/constants/app.constants';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
-import { Permission } from '~/app/shared/models/permissions';
+import { Permissions } from '~/app/shared/models/permissions';
+import { WizardStepModel } from '~/app/shared/models/wizard-steps';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
import { NotificationService } from '~/app/shared/services/notification.service';
+import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
@Component({
selector: 'cd-create-cluster',
templateUrl: './create-cluster.component.html',
styleUrls: ['./create-cluster.component.scss']
})
-export class CreateClusterComponent {
- permission: Permission;
- orchStatus = false;
- featureAvailable = false;
+export class CreateClusterComponent implements OnDestroy {
+ @ViewChild('skipConfirmTpl', { static: true })
+ skipConfirmTpl: TemplateRef<any>;
+ currentStep: WizardStepModel;
+ currentStepSub: Subscription;
+ permissions: Permissions;
projectConstants: typeof AppConstants = AppConstants;
+ stepTitles = ['Add Hosts', 'Review'];
+ startClusterCreation = false;
+ observables: any = [];
+ modalRef: NgbModalRef;
constructor(
private authStorageService: AuthStorageService,
+ private stepsService: WizardStepsService,
+ private router: Router,
+ private hostService: HostService,
+ private notificationService: NotificationService,
+ private actionLabels: ActionLabelsI18n,
private clusterService: ClusterService,
- private notificationService: NotificationService
+ private modalService: ModalService
) {
- this.permission = this.authStorageService.getPermissions().configOpt;
+ this.permissions = this.authStorageService.getPermissions();
+ this.currentStepSub = this.stepsService.getCurrentStep().subscribe((step: WizardStepModel) => {
+ this.currentStep = step;
+ });
+ this.currentStep.stepIndex = 1;
}
createCluster() {
- this.notificationService.show(
- NotificationType.error,
- $localize`Cluster creation feature not implemented`
- );
+ this.startClusterCreation = true;
}
skipClusterCreation() {
- this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => {
- this.notificationService.show(
- NotificationType.info,
- $localize`Cluster creation skipped by user`
- );
- });
+ const modalVariables = {
+ titleText: $localize`Warning`,
+ buttonText: $localize`Continue`,
+ warning: true,
+ bodyTpl: this.skipConfirmTpl,
+ showSubmit: true,
+ onSubmit: () => {
+ this.clusterService.updateStatus('POST_INSTALLED').subscribe({
+ error: () => this.modalRef.close(),
+ complete: () => {
+ this.notificationService.show(
+ NotificationType.info,
+ $localize`Cluster expansion skipped by user`
+ );
+ this.router.navigate(['/dashboard']);
+ this.modalRef.close();
+ }
+ });
+ }
+ };
+ this.modalRef = this.modalService.show(ConfirmationModalComponent, modalVariables);
+ }
+
+ onSubmit() {
+ forkJoin(this.observables)
+ .pipe(
+ finalize(() =>
+ this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Cluster expansion was successful`
+ );
+ this.router.navigate(['/dashboard']);
+ })
+ )
+ )
+ .subscribe({
+ error: (error) => error.preventDefault()
+ });
+ }
+
+ onNextStep() {
+ if (!this.stepsService.isLastStep()) {
+ this.hostService.list().subscribe((hosts) => {
+ hosts.forEach((host) => {
+ if (host['status'] === 'maintenance') {
+ this.observables.push(this.hostService.update(host['hostname'], false, [], true));
+ }
+ });
+ });
+ this.stepsService.moveToNextStep();
+ } else {
+ this.onSubmit();
+ }
+ }
+
+ onPreviousStep() {
+ if (!this.stepsService.isFirstStep()) {
+ this.stepsService.moveToPreviousStep();
+ } else {
+ this.router.navigate(['/dashboard']);
+ }
+ }
+
+ showSubmitButtonLabel() {
+ return !this.stepsService.isLastStep() ? this.actionLabels.NEXT : $localize`Expand Cluster`;
+ }
+
+ showCancelButtonLabel() {
+ return !this.stepsService.isFirstStep() ? this.actionLabels.BACK : this.actionLabels.CANCEL;
+ }
+
+ ngOnDestroy(): void {
+ this.currentStepSub.unsubscribe();
}
}
-<div class="cd-col-form"
- *cdFormLoading="loading">
- <form name="hostForm"
- #formDir="ngForm"
- [formGroup]="hostForm"
- novalidate>
- <div class="card">
- <div i18n="form title"
- class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+<cd-modal [pageURL]="pageURL"
+ [modalRef]="activeModal">
+ <span class="modal-title"
+ i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
- <div class="card-body">
+ <ng-container class="modal-content">
- <!-- Hostname -->
- <div class="form-group row">
- <label class="cd-col-form-label required"
- for="hostname"
- i18n>Hostname</label>
- <div class="cd-col-form-input">
- <input class="form-control"
- type="text"
- placeholder="mon-123"
- id="hostname"
- name="hostname"
- formControlName="hostname"
- autofocus>
- <span class="invalid-feedback"
- *ngIf="hostForm.showError('hostname', formDir, 'required')"
- i18n>This field is required.</span>
- <span class="invalid-feedback"
- *ngIf="hostForm.showError('hostname', formDir, 'uniqueName')"
- i18n>The chosen hostname is already in use.</span>
+ <div *cdFormLoading="loading">
+ <form name="hostForm"
+ #formDir="ngForm"
+ [formGroup]="hostForm"
+ novalidate>
+
+ <div class="modal-body">
+
+ <!-- Hostname -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="hostname"
+ i18n>Hostname</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="mon-123"
+ id="hostname"
+ name="hostname"
+ formControlName="hostname"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="hostForm.showError('hostname', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="hostForm.showError('hostname', formDir, 'uniqueName')"
+ i18n>The chosen hostname is already in use.</span>
+ </div>
</div>
- </div>
- <!-- Address -->
- <div class="form-group row">
- <label class="cd-col-form-label"
- for="addr"
- i18n>Nework address</label>
- <div class="cd-col-form-input">
- <input class="form-control"
- type="text"
- placeholder="192.168.0.1"
- id="addr"
- name="addr"
- formControlName="addr">
- <span class="invalid-feedback"
- *ngIf="hostForm.showError('addr', formDir, 'pattern')"
- i18n>The value is not a valid IP address.</span>
+ <!-- Address -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="addr"
+ i18n>Nework address</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="192.168.0.1"
+ id="addr"
+ name="addr"
+ formControlName="addr">
+ <span class="invalid-feedback"
+ *ngIf="hostForm.showError('addr', formDir, 'pattern')"
+ i18n>The value is not a valid IP address.</span>
+ </div>
</div>
- </div>
- <!-- Labels -->
- <div class="form-group row">
- <label i18n
- for="labels"
- class="cd-col-form-label">Labels</label>
- <div class="cd-col-form-input">
- <cd-select-badges id="labels"
- [data]="hostForm.controls.labels.value"
- [customBadges]="true"
- [messages]="messages">
- </cd-select-badges>
+ <!-- Labels -->
+ <div class="form-group row">
+ <label i18n
+ for="labels"
+ class="cd-col-form-label">Labels</label>
+ <div class="cd-col-form-input">
+ <cd-select-badges id="labels"
+ [data]="hostForm.controls.labels.value"
+ [customBadges]="true"
+ [messages]="messages">
+ </cd-select-badges>
+ </div>
</div>
- </div>
- <!-- Maintenance Mode -->
- <div class="form-group row">
- <div class="cd-col-form-offset">
- <div class="custom-control custom-checkbox">
- <input class="custom-control-input"
- id="maintenance"
- type="checkbox"
- formControlName="maintenance">
- <label class="custom-control-label"
- for="maintenance"
- i18n>Maintenance Mode</label>
+ <!-- Maintenance Mode -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="maintenance"
+ type="checkbox"
+ formControlName="maintenance">
+ <label class="custom-control-label"
+ for="maintenance"
+ i18n>Maintenance Mode</label>
+ </div>
</div>
</div>
</div>
- </div>
- <div class="card-footer">
- <cd-form-button-panel (submitActionEvent)="submit()"
- [form]="hostForm"
- [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
- wrappingClass="text-right"></cd-form-button-panel>
- </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="hostForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </form>
</div>
- </form>
-</div>
+ </ng-container>
+</cd-modal>
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrModule } from 'ngx-toastr';
import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component';
ReactiveFormsModule,
ToastrModule.forRoot()
],
- declarations: [HostFormComponent]
+ declarations: [HostFormComponent],
+ providers: [NgbActiveModal]
},
[LoadingPanelComponent]
);
beforeEach(() => {
fixture = TestBed.createComponent(HostFormComponent);
component = fixture.componentInstance;
+ component.ngOnInit();
formHelper = new FormHelper(component.hostForm);
fixture.detectChanges();
});
expect(component).toBeTruthy();
});
+ it('should open the form in a modal', () => {
+ const nativeEl = fixture.debugElement.nativeElement;
+ expect(nativeEl.querySelector('cd-modal')).not.toBe(null);
+ });
+
it('should validate the network address is valid', fakeAsync(() => {
formHelper.setValue('addr', '115.42.150.37', true);
tick();
import { FormControl, Validators } from '@angular/forms';
import { Router } from '@angular/router';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
import { HostService } from '~/app/shared/api/host.service';
import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
addr: string;
status: string;
allLabels: any;
+ pageURL: string;
messages = new SelectMessages({
empty: $localize`There are no labels.`,
private router: Router,
private actionLabels: ActionLabelsI18n,
private hostService: HostService,
- private taskWrapper: TaskWrapperService
+ private taskWrapper: TaskWrapperService,
+ public activeModal: NgbActiveModal
) {
super();
this.resource = $localize`host`;
- this.action = this.actionLabels.CREATE;
- this.createForm();
+ this.action = this.actionLabels.ADD;
}
ngOnInit() {
+ if (this.router.url.includes('hosts')) {
+ this.pageURL = 'hosts';
+ }
+ this.createForm();
this.hostService.list().subscribe((resp: any[]) => {
this.hostnames = resp.map((host) => {
return host['hostname'];
}
private createForm() {
+ const disableMaintenance = this.pageURL !== 'hosts';
this.hostForm = new CdFormGroup({
hostname: new FormControl('', {
validators: [
validators: [CdValidators.ip()]
}),
labels: new FormControl([]),
- maintenance: new FormControl(false)
+ maintenance: new FormControl({ value: disableMaintenance, disabled: disableMaintenance })
});
}
this.allLabels = this.hostForm.get('labels').value;
this.taskWrapper
.wrapTaskAroundCall({
- task: new FinishedTask('host/' + URLVerbs.CREATE, {
+ task: new FinishedTask('host/' + URLVerbs.ADD, {
hostname: hostname
}),
call: this.hostService.create(hostname, this.addr, this.allLabels, this.status)
this.hostForm.setErrors({ cdSubmitButton: true });
},
complete: () => {
- this.router.navigate(['/hosts']);
+ this.pageURL === 'hosts'
+ ? this.router.navigate([this.pageURL, { outlets: { modal: null } }])
+ : this.activeModal.close();
}
});
}
<a ngbNavLink
i18n>Hosts List</a>
<ng-template ngbNavContent>
- <cd-table [data]="hosts"
+ <cd-table #table
+ [data]="hosts"
[columns]="columns"
columnMode="flex"
(fetchData)="getHosts($event)"
selectionType="single"
- [hasDetails]="true"
+ [hasDetails]="!clusterCreation"
(setExpandedRow)="setExpandedRow($event)"
(updateSelection)="updateSelection($event)">
<div class="table-actions btn-toolbar">
</ng-template>
</li>
<li ngbNavItem
- *ngIf="permissions.grafana.read">
+ *ngIf="permissions.grafana.read && !clusterCreation">
<a ngbNavLink
i18n>Overall Performance</a>
<ng-template ngbNavContent>
<ng-container i18n
*ngIf="showSubmit">Are you sure you want to continue?</ng-container>
</ng-template>
+<router-outlet name="modal"></router-outlet>
showForceMaintenanceModal = new MockShowForceMaintenanceModal();
fixture = TestBed.createComponent(HostsComponent);
component = fixture.componentInstance;
+ component.clusterCreation = false;
hostListSpy = spyOn(TestBed.inject(HostService), 'list');
orchService = TestBed.inject(OrchestratorService);
});
const tests = [
{
expectResults: {
- Create: { disabled: false, disableDesc: '' },
+ Add: { disabled: false, disableDesc: '' },
Edit: { disabled: true, disableDesc: '' },
Delete: { disabled: true, disableDesc: '' }
}
{
selectRow: fakeHosts[0], // non-orchestrator host
expectResults: {
- Create: { disabled: false, disableDesc: '' },
+ Add: { disabled: false, disableDesc: '' },
Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
Delete: { disabled: true, disableDesc: component.messages.nonOrchHost }
}
{
selectRow: fakeHosts[1], // orchestrator host
expectResults: {
- Create: { disabled: false, disableDesc: '' },
+ Add: { disabled: false, disableDesc: '' },
Edit: { disabled: false, disableDesc: '' },
Delete: { disabled: false, disableDesc: '' }
}
const tests = [
{
expectResults: {
- Create: resultNoOrchestrator,
+ Add: resultNoOrchestrator,
Edit: { disabled: true, disableDesc: '' },
Delete: { disabled: true, disableDesc: '' }
}
{
selectRow: fakeHosts[0], // non-orchestrator host
expectResults: {
- Create: resultNoOrchestrator,
+ Add: resultNoOrchestrator,
Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
Delete: { disabled: true, disableDesc: component.messages.nonOrchHost }
}
{
selectRow: fakeHosts[1], // orchestrator host
expectResults: {
- Create: resultNoOrchestrator,
+ Add: resultNoOrchestrator,
Edit: resultNoOrchestrator,
Delete: resultNoOrchestrator
}
const tests = [
{
expectResults: {
- Create: resultMissingFeatures,
+ Add: resultMissingFeatures,
Edit: { disabled: true, disableDesc: '' },
Delete: { disabled: true, disableDesc: '' }
}
{
selectRow: fakeHosts[0], // non-orchestrator host
expectResults: {
- Create: resultMissingFeatures,
+ Add: resultMissingFeatures,
Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
Delete: { disabled: true, disableDesc: component.messages.nonOrchHost }
}
{
selectRow: fakeHosts[1], // orchestrator host
expectResults: {
- Create: resultMissingFeatures,
+ Add: resultMissingFeatures,
Edit: resultMissingFeatures,
Delete: resultMissingFeatures
}
-import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
-import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
import { NotificationService } from '~/app/shared/services/notification.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { HostFormComponent } from './host-form/host-form.component';
const BASE_URL = 'hosts';
public servicesTpl: TemplateRef<any>;
@ViewChild('maintenanceConfirmTpl', { static: true })
maintenanceConfirmTpl: TemplateRef<any>;
+ @Input()
+ clusterCreation = false;
permissions: Permissions;
columns: Array<CdTableColumn> = [];
isExecuting = false;
errorMessage: string;
enableButton: boolean;
+ pageURL: string;
+ bsModalRef: NgbModalRef;
icons = Icons;
orchStatus: OrchestratorStatus;
actionOrchFeatures = {
- create: [OrchestratorFeature.HOST_CREATE],
+ add: [OrchestratorFeature.HOST_CREATE],
edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE],
delete: [OrchestratorFeature.HOST_DELETE],
maintenance: [
private authStorageService: AuthStorageService,
private hostService: HostService,
private cephShortVersionPipe: CephShortVersionPipe,
- private urlBuilder: URLBuilderService,
private actionLabels: ActionLabelsI18n,
private modalService: ModalService,
private taskWrapper: TaskWrapperService,
super();
this.permissions = this.authStorageService.getPermissions();
this.tableActions = [
- {
- name: this.actionLabels.CREATE,
- permission: 'create',
- icon: Icons.add,
- click: () => this.router.navigate([this.urlBuilder.getCreate()]),
- disable: (selection: CdTableSelection) => this.getDisable('create', selection)
- },
{
name: this.actionLabels.EDIT,
permission: 'update',
icon: Icons.enter,
click: () => this.hostMaintenance(),
disable: (selection: CdTableSelection) =>
- this.getDisable('maintenance', selection) || this.isExecuting || this.enableButton
+ this.getDisable('maintenance', selection) ||
+ this.isExecuting ||
+ this.enableButton ||
+ this.clusterCreation
},
{
name: this.actionLabels.EXIT_MAINTENANCE,
icon: Icons.exit,
click: () => this.hostMaintenance(),
disable: (selection: CdTableSelection) =>
- this.getDisable('maintenance', selection) || this.isExecuting || !this.enableButton
+ this.getDisable('maintenance', selection) ||
+ this.isExecuting ||
+ !this.enableButton ||
+ this.clusterCreation
}
];
}
ngOnInit() {
+ this.tableActions.unshift({
+ name: this.actionLabels.ADD,
+ permission: 'create',
+ icon: Icons.add,
+ click: () =>
+ this.clusterCreation
+ ? (this.bsModalRef = this.modalService.show(HostFormComponent))
+ : this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.ADD] } }]),
+ disable: (selection: CdTableSelection) => this.getDisable('add', selection)
+ });
this.columns = [
{
name: $localize`Hostname`,
{
name: $localize`Services`,
prop: 'services',
+ isHidden: this.clusterCreation,
flexGrow: 3,
cellTemplate: this.servicesTpl
},
{
name: $localize`Version`,
prop: 'ceph_version',
+ isHidden: this.clusterCreation,
flexGrow: 1,
pipe: this.cephShortVersionPipe
}
}
getDisable(
- action: 'create' | 'edit' | 'delete' | 'maintenance',
+ action: 'add' | 'edit' | 'delete' | 'maintenance',
selection: CdTableSelection
): boolean | string {
if (action === 'delete' || action === 'edit' || action === 'maintenance') {
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
import { NgbActiveModal, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ToastrModule } from 'ngx-toastr';
ReactiveFormsModule,
SharedModule,
ToastrModule.forRoot(),
- NgbTooltipModule
+ NgbTooltipModule,
+ RouterTestingModule
],
declarations: [OsdFlagsIndivModalComponent],
providers: [NgbActiveModal]
component.login();
expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
- expect(routerNavigateSpy).toHaveBeenCalledWith(['/create-cluster']);
+ expect(routerNavigateSpy).toHaveBeenCalledWith(['/expand-cluster']);
});
});
login() {
this.authService.login(this.model).subscribe(() => {
- const urlPath = this.postInstalled ? '/' : '/create-cluster';
+ const urlPath = this.postInstalled ? '/' : '/expand-cluster';
let url = _.get(this.route.snapshot.queryParams, 'returnUrl', urlPath);
if (!this.postInstalled && this.route.snapshot.queryParams['returnUrl'] === '/dashboard') {
- url = '/create-cluster';
+ url = '/expand-cluster';
}
this.router.navigate([url]);
});
import { SubmitButtonComponent } from './submit-button/submit-button.component';
import { TelemetryNotificationComponent } from './telemetry-notification/telemetry-notification.component';
import { UsageBarComponent } from './usage-bar/usage-bar.component';
+import { WizardComponent } from './wizard/wizard.component';
@NgModule({
imports: [
Copy2ClipboardButtonComponent,
DownloadButtonComponent,
FormButtonPanelComponent,
- MotdComponent
+ MotdComponent,
+ WizardComponent
],
providers: [],
exports: [
Copy2ClipboardButtonComponent,
DownloadButtonComponent,
FormButtonPanelComponent,
- MotdComponent
+ MotdComponent,
+ WizardComponent
]
})
export class ComponentsModule {}
-<div class="modal-header">
- <h4 class="modal-title float-left">
- <ng-content select=".modal-title"></ng-content>
- </h4>
- <button type="button"
- class="close float-right"
- aria-label="Close"
- (click)="close()">
- <span aria-hidden="true">×</span>
- </button>
-</div>
+<div [ngClass]="pageURL ? 'modal' : ''">
+ <div [ngClass]="pageURL ? 'modal-dialog' : ''">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title float-left">
+ <ng-content select=".modal-title"></ng-content>
+ </h4>
+ <button type="button"
+ class="close float-right"
+ aria-label="Close"
+ (click)="close()">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
-<ng-content select=".modal-content"></ng-content>
+ <ng-content select=".modal-content"></ng-content>
+ </div>
+ </div>
+</div>
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
describe('ModalComponent', () => {
let component: ModalComponent;
let fixture: ComponentFixture<ModalComponent>;
+ let routerNavigateSpy: jasmine.Spy;
configureTestBed({
- declarations: [ModalComponent]
+ declarations: [ModalComponent],
+ imports: [RouterTestingModule]
});
beforeEach(() => {
fixture = TestBed.createComponent(ModalComponent);
component = fixture.componentInstance;
+ routerNavigateSpy = spyOn(TestBed.inject(Router), 'navigate');
+ routerNavigateSpy.and.returnValue(true);
fixture.detectChanges();
});
component.close();
expect(component.modalRef.close).toHaveBeenCalled();
});
+
+ it('should hide the routed modal', () => {
+ component.pageURL = 'hosts';
+ component.close();
+ expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
+ expect(routerNavigateSpy).toHaveBeenCalledWith(['hosts', { outlets: { modal: null } }]);
+ });
});
import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { Router } from '@angular/router';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
export class ModalComponent {
@Input()
modalRef: NgbActiveModal;
+ @Input()
+ pageURL: string;
/**
* Should be a function that is triggered when the modal is hidden.
@Output()
hide = new EventEmitter();
+ constructor(private router: Router) {}
+
close() {
- this.modalRef?.close();
+ this.pageURL
+ ? this.router.navigate([this.pageURL, { outlets: { modal: null } }])
+ : this.modalRef?.close();
this.hide.emit();
}
}
--- /dev/null
+<div class="card-body">
+ <div class="row m-7">
+ <nav class="col">
+ <ul class="nav nav-pills flex-column"
+ *ngFor="let step of steps | async; let i = index;">
+ <li class="nav-item">
+ <a class="nav-link"
+ (click)="onStepClick(step)"
+ [ngClass]="{active: currentStep.stepIndex === step.stepIndex}">
+ <span class="circle-step"
+ [ngClass]="{active: currentStep.stepIndex === step.stepIndex}"
+ i18n>{{ step.stepIndex }}</span>
+ <span i18n>{{ stepsTitle[i] }}</span>
+ </a>
+ </li>
+ </ul>
+ </nav>
+ </div>
+</div>
--- /dev/null
+@use './src/styles/vendor/variables' as vv;
+
+.card-body {
+ padding-left: 0;
+}
+
+span.circle-step {
+ background: vv.$gray-500;
+ border-radius: 0.8em;
+ color: vv.$white;
+ display: inline-block;
+ font-weight: bold;
+ line-height: 1.6em;
+ margin-right: 5px;
+ text-align: center;
+ width: 1.6em;
+
+ &.active {
+ background-color: vv.$primary;
+ }
+}
+
+.nav-pills .nav-link {
+ background-color: vv.$white;
+ color: vv.$gray-800;
+
+ &.active {
+ color: vv.$primary;
+ }
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { WizardComponent } from './wizard.component';
+
+describe('WizardComponent', () => {
+ let component: WizardComponent;
+ let fixture: ComponentFixture<WizardComponent>;
+
+ configureTestBed({
+ imports: [SharedModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(WizardComponent);
+ component = fixture.componentInstance;
+ component.stepsTitle = ['Add Hosts', 'Review'];
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, Input, OnDestroy, OnInit } from '@angular/core';
+
+import * as _ from 'lodash';
+import { Observable, Subscription } from 'rxjs';
+
+import { WizardStepModel } from '~/app/shared/models/wizard-steps';
+import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
+
+@Component({
+ selector: 'cd-wizard',
+ templateUrl: './wizard.component.html',
+ styleUrls: ['./wizard.component.scss']
+})
+export class WizardComponent implements OnInit, OnDestroy {
+ @Input()
+ stepsTitle: string[];
+
+ steps: Observable<WizardStepModel[]>;
+ currentStep: WizardStepModel;
+ currentStepSub: Subscription;
+
+ constructor(private stepsService: WizardStepsService) {}
+
+ ngOnInit(): void {
+ this.stepsService.setTotalSteps(this.stepsTitle.length);
+ this.steps = this.stepsService.getSteps();
+ this.currentStepSub = this.stepsService.getCurrentStep().subscribe((step: WizardStepModel) => {
+ this.currentStep = step;
+ });
+ }
+
+ onStepClick(step: WizardStepModel) {
+ this.stepsService.setCurrentStep(step);
+ }
+
+ ngOnDestroy(): void {
+ this.currentStepSub.unsubscribe();
+ }
+}
--- /dev/null
+export interface WizardStepModel {
+ stepIndex: number;
+ isComplete: boolean;
+}
messages = {
// Host tasks
- 'host/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
- this.host(metadata)
- ),
+ 'host/add': this.newTaskMessage(this.commonOperations.add, (metadata) => this.host(metadata)),
'host/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.host(metadata)
),
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { WizardStepsService } from './wizard-steps.service';
+
+describe('WizardStepsService', () => {
+ let service: WizardStepsService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(WizardStepsService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
--- /dev/null
+import { Injectable } from '@angular/core';
+
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { WizardStepModel } from '~/app/shared/models/wizard-steps';
+
+const initialStep = [{ stepIndex: 1, isComplete: false }];
+
+@Injectable({
+ providedIn: 'root'
+})
+export class WizardStepsService {
+ steps$: BehaviorSubject<WizardStepModel[]>;
+ currentStep$: BehaviorSubject<WizardStepModel> = new BehaviorSubject<WizardStepModel>(null);
+
+ constructor() {
+ this.steps$ = new BehaviorSubject<WizardStepModel[]>(initialStep);
+ this.currentStep$.next(this.steps$.value[0]);
+ }
+
+ setTotalSteps(step: number) {
+ const steps: WizardStepModel[] = [];
+ for (let i = 1; i <= step; i++) {
+ steps.push({ stepIndex: i, isComplete: false });
+ }
+ this.steps$ = new BehaviorSubject<WizardStepModel[]>(steps);
+ }
+
+ setCurrentStep(step: WizardStepModel): void {
+ this.currentStep$.next(step);
+ }
+
+ getCurrentStep(): Observable<WizardStepModel> {
+ return this.currentStep$.asObservable();
+ }
+
+ getSteps(): Observable<WizardStepModel[]> {
+ return this.steps$.asObservable();
+ }
+
+ moveToNextStep(): void {
+ const index = this.currentStep$.value.stepIndex;
+ this.currentStep$.next(this.steps$.value[index]);
+ }
+
+ moveToPreviousStep(): void {
+ const index = this.currentStep$.value.stepIndex - 1;
+ this.currentStep$.next(this.steps$.value[index - 1]);
+ }
+
+ isLastStep(): boolean {
+ return this.currentStep$.value.stepIndex === this.steps$.value.length;
+ }
+
+ isFirstStep(): boolean {
+ return this.currentStep$.value?.stepIndex - 1 === 0;
+ }
+}
}
cd-modal {
+ .modal {
+ /* stylelint-disable */
+ background-color: rgba(0, 0, 0, 0.4);
+ /* stylelint-enable */
+ display: block;
+ }
+
+ .modal-dialog {
+ max-width: 70vh;
+ }
+
.cd-col-form-label {
@extend .col-lg-4;
}