Add host section of the cluster creation workflow.
1. Fix bug in the modal where going forward one step on the wizard and coming back opens up the add host modal.
2. Rename Create Cluster to Expand Cluster as per the discussions
3. A skip confirmation modal to warn the user when he tries to skip the
cluster creation
4. Adapted all the tests
5. Did some UI improvements like fixing and aligning the styles,
colors..
- Used routed modal for host Additon form
- Renamed the Create to Add in Host Form
Fixes: https://tracker.ceph.com/issues/51517
Fixes: https://tracker.ceph.com/issues/51640
Fixes: https://tracker.ceph.com/issues/50336
Fixes: https://tracker.ceph.com/issues/50565
Signed-off-by: Avan Thakkar <athakkar@redhat.com>
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
Signed-off-by: Nizamudeen A <nia@redhat.com>
- 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;
}