]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cluster Creation Add Host Section and e2es
authorNizamudeen A <nia@redhat.com>
Sun, 4 Jul 2021 13:16:45 +0000 (18:46 +0530)
committerNizamudeen A <nia@redhat.com>
Wed, 13 Oct 2021 10:25:23 +0000 (15:55 +0530)
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>
42 files changed:
qa/suites/rados/dashboard/tasks/dashboard.yaml
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts [deleted file]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts [deleted file]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster.po.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-create-cluster-welcome-page.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/02-create-cluster-add-host.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/03-create-cluster-review.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/04-cluster-check.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss

index 1d0ec7395eae3c75405f3227273622d56c14fca5..db28999ece922319ecb6956be50db8be35ee3c9c 100644 (file)
@@ -39,6 +39,7 @@ tasks:
         - 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
index a72c4b34052d5e6d5c9c052f0098bf851fe9331d..6ba1a5e0f1cbbfbbe77e685d35f7e4f2fc2c131e 100644 (file)
@@ -285,7 +285,7 @@ class Host(RESTController):
 
     @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'),
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts
deleted file mode 100644 (file)
index 5615b03..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-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');
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts
deleted file mode 100644 (file)
index 58844e3..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-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');
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster.po.ts
new file mode 100644 (file)
index 0000000..22941b2
--- /dev/null
@@ -0,0 +1,113 @@
+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);
+          }
+        }
+      });
+  }
+}
index 6752fe9e7870cb511c98f8ff9ff073137ceeeb6e..7a7e00d6648ace7d6896d2ceb57202a37fe5187e 100644 (file)
@@ -2,7 +2,7 @@ import { PageHelper } from '../page-helper.po';
 
 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 {
@@ -49,21 +49,20 @@ 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)
index cf85642a1b1d2ef997d10bb9e65f1bed895998a5..6c79a74662dff6aaea887465d44b9394205e2dcd 100644 (file)
@@ -17,7 +17,7 @@ describe('Hosts page', () => {
 
     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);
     });
 
@@ -26,7 +26,7 @@ describe('Hosts page', () => {
       hosts.delete(host);
 
       // add it back
-      hosts.navigateTo('create');
+      hosts.navigateTo('add');
       hosts.add(host);
       hosts.checkExist(host, true);
     });
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts
deleted file mode 100644 (file)
index bd0470b..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-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();
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-create-cluster-welcome-page.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-create-cluster-welcome-page.e2e-spec.ts
new file mode 100644 (file)
index 0000000..d776a21
--- /dev/null
@@ -0,0 +1,19 @@
+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();
+  });
+});
index 7dd0c1104334c72ecfd5364c3a5042984e011cbf..e8dea8e30080fa991a10697363afcf0a39407c44 100644 (file)
@@ -12,7 +12,7 @@ describe('Hosts page', () => {
     '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);
   };
@@ -49,7 +49,7 @@ describe('Hosts page', () => {
     });
 
     it('should not add an existing host', function () {
-      hosts.navigateTo('create');
+      hosts.navigateTo('add');
       hosts.add(hostnames[0], true);
     });
 
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/02-create-cluster-add-host.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/02-create-cluster-add-host.e2e-spec.ts
new file mode 100644 (file)
index 0000000..7bf5b5b
--- /dev/null
@@ -0,0 +1,48 @@
+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]);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/03-create-cluster-review.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/03-create-cluster-review.e2e-spec.ts
new file mode 100644 (file)
index 0000000..17dd849
--- /dev/null
@@ -0,0 +1,59 @@
+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();
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/04-cluster-check.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/04-cluster-check.e2e-spec.ts
new file mode 100644 (file)
index 0000000..9717dd8
--- /dev/null
@@ -0,0 +1,49 @@
+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);
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts
deleted file mode 100644 (file)
index a472810..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-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();
-    });
-  });
-});
index 099b31efbda5033950cdde84a6b872d35b3c7a39..bdd2cb89782346d0f00aa9397397cbef20366c6b 100644 (file)
@@ -91,7 +91,7 @@ const routes: Routes = [
 
       // Cluster
       {
-        path: 'create-cluster',
+        path: 'expand-cluster',
         component: CreateClusterComponent,
         canActivate: [ModuleStatusGuardService],
         data: {
@@ -100,18 +100,18 @@ const routes: Routes = [
             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'
           }
         ]
       },
index a2c1e6d2f89ecd8c93791490036c7014e3caa231..185c34b27504dd640f785a47141cefc285b33c2e 100644 (file)
@@ -5,6 +5,7 @@ import { RouterModule } from '@angular/router';
 
 import { TreeModule } from '@circlon/angular-tree-component';
 import {
+  NgbActiveModal,
   NgbDatepickerModule,
   NgbDropdownModule,
   NgbNavModule,
@@ -106,7 +107,6 @@ import { TelemetryComponent } from './telemetry/telemetry.component';
     OsdCreationPreviewModalComponent,
     RulesListComponent,
     ActiveAlertListComponent,
-    HostFormComponent,
     ServiceDetailsComponent,
     ServiceDaemonListComponent,
     TelemetryComponent,
@@ -115,6 +115,7 @@ import { TelemetryComponent } from './telemetry/telemetry.component';
     OsdFlagsIndivModalComponent,
     PlacementPipe,
     CreateClusterComponent
-  ]
+  ],
+  providers: [NgbActiveModal]
 })
 export class ClusterModule {}
index 661c13fc931c9307b32936b3310b0306c60d81e9..fb006ec1beb104d31253ee4fe0cb8ca382c8c595 100644 (file)
@@ -1,4 +1,5 @@
-<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>
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..580c5219e9f2c0c93f55c251e07616b9bf680e45 100644 (file)
@@ -0,0 +1,26 @@
+@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;
+  }
+}
index 7e061b2e25c9af92059cf217651b12aecf9db6e0..1ebdfb3a59d2f93ea46012b5929ece2723804e02 100644 (file)
@@ -5,7 +5,13 @@ import { RouterTestingModule } from '@angular/router/testing';
 
 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';
@@ -13,17 +19,31 @@ 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();
   });
 
@@ -31,15 +51,65 @@ describe('CreateClusterComponent', () => {
     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');
   });
 });
index 239a4f13ca7f069a50c1660b0dd3b7cb6f7afe66..b47a63e8cec008c1e8ec6166b8711f385d3b98bb 100644 (file)
-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();
   }
 }
index 487caf85b12deecf990cf373507246ccf56bb6c6..a3477b9bd4102318bf80b08beb36a741d31a6c02 100644 (file)
@@ -1,91 +1,95 @@
-<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>
index dbb834ea8c82cc7fa37ef6b0ac829638e09386db..ed3daf1e4b49b75c60d76028d7f768e6b14a573e 100644 (file)
@@ -3,6 +3,7 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin
 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';
@@ -24,7 +25,8 @@ describe('HostFormComponent', () => {
         ReactiveFormsModule,
         ToastrModule.forRoot()
       ],
-      declarations: [HostFormComponent]
+      declarations: [HostFormComponent],
+      providers: [NgbActiveModal]
     },
     [LoadingPanelComponent]
   );
@@ -32,6 +34,7 @@ describe('HostFormComponent', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(HostFormComponent);
     component = fixture.componentInstance;
+    component.ngOnInit();
     formHelper = new FormHelper(component.hostForm);
     fixture.detectChanges();
   });
@@ -40,6 +43,11 @@ describe('HostFormComponent', () => {
     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();
index b90312ff855f2f305904961cea58fc9139ffdae4..2fc8b13b540e6fe0de5928ad369b9cb19bf6dd86 100644 (file)
@@ -2,6 +2,8 @@ import { Component, OnInit } from '@angular/core';
 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';
@@ -24,6 +26,7 @@ export class HostFormComponent extends CdForm implements OnInit {
   addr: string;
   status: string;
   allLabels: any;
+  pageURL: string;
 
   messages = new SelectMessages({
     empty: $localize`There are no labels.`,
@@ -35,15 +38,19 @@ export class HostFormComponent extends CdForm implements OnInit {
     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'];
@@ -53,6 +60,7 @@ export class HostFormComponent extends CdForm implements OnInit {
   }
 
   private createForm() {
+    const disableMaintenance = this.pageURL !== 'hosts';
     this.hostForm = new CdFormGroup({
       hostname: new FormControl('', {
         validators: [
@@ -66,7 +74,7 @@ export class HostFormComponent extends CdForm implements OnInit {
         validators: [CdValidators.ip()]
       }),
       labels: new FormControl([]),
-      maintenance: new FormControl(false)
+      maintenance: new FormControl({ value: disableMaintenance, disabled: disableMaintenance })
     });
   }
 
@@ -77,7 +85,7 @@ export class HostFormComponent extends CdForm implements OnInit {
     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)
@@ -87,7 +95,9 @@ export class HostFormComponent extends CdForm implements OnInit {
           this.hostForm.setErrors({ cdSubmitButton: true });
         },
         complete: () => {
-          this.router.navigate(['/hosts']);
+          this.pageURL === 'hosts'
+            ? this.router.navigate([this.pageURL, { outlets: { modal: null } }])
+            : this.activeModal.close();
         }
       });
   }
index f31adf9e5c0e79bc21bb172c3ce629c23f9f105a..59bab46d72d237160f000f46f3cb98bbbe0c130d 100644 (file)
@@ -5,12 +5,13 @@
     <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">
@@ -29,7 +30,7 @@
     </ng-template>
   </li>
   <li ngbNavItem
-      *ngIf="permissions.grafana.read">
+      *ngIf="permissions.grafana.read && !clusterCreation">
     <a ngbNavLink
        i18n>Overall Performance</a>
     <ng-template ngbNavContent>
@@ -66,3 +67,4 @@
   <ng-container i18n
                 *ngIf="showSubmit">Are you sure you want to continue?</ng-container>
 </ng-template>
+<router-outlet name="modal"></router-outlet>
index f4d400f3230c8cf8424c5a040c21d432aa468737..049aceba0994a77ad2af35e95ea60f63a36682f3 100644 (file)
@@ -72,6 +72,7 @@ describe('HostsComponent', () => {
     showForceMaintenanceModal = new MockShowForceMaintenanceModal();
     fixture = TestBed.createComponent(HostsComponent);
     component = fixture.componentInstance;
+    component.clusterCreation = false;
     hostListSpy = spyOn(TestBed.inject(HostService), 'list');
     orchService = TestBed.inject(OrchestratorService);
   });
@@ -182,7 +183,7 @@ describe('HostsComponent', () => {
       const tests = [
         {
           expectResults: {
-            Create: { disabled: false, disableDesc: '' },
+            Add: { disabled: false, disableDesc: '' },
             Edit: { disabled: true, disableDesc: '' },
             Delete: { disabled: true, disableDesc: '' }
           }
@@ -190,7 +191,7 @@ describe('HostsComponent', () => {
         {
           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 }
           }
@@ -198,7 +199,7 @@ describe('HostsComponent', () => {
         {
           selectRow: fakeHosts[1], // orchestrator host
           expectResults: {
-            Create: { disabled: false, disableDesc: '' },
+            Add: { disabled: false, disableDesc: '' },
             Edit: { disabled: false, disableDesc: '' },
             Delete: { disabled: false, disableDesc: '' }
           }
@@ -222,7 +223,7 @@ describe('HostsComponent', () => {
       const tests = [
         {
           expectResults: {
-            Create: resultNoOrchestrator,
+            Add: resultNoOrchestrator,
             Edit: { disabled: true, disableDesc: '' },
             Delete: { disabled: true, disableDesc: '' }
           }
@@ -230,7 +231,7 @@ describe('HostsComponent', () => {
         {
           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 }
           }
@@ -238,7 +239,7 @@ describe('HostsComponent', () => {
         {
           selectRow: fakeHosts[1], // orchestrator host
           expectResults: {
-            Create: resultNoOrchestrator,
+            Add: resultNoOrchestrator,
             Edit: resultNoOrchestrator,
             Delete: resultNoOrchestrator
           }
@@ -255,7 +256,7 @@ describe('HostsComponent', () => {
       const tests = [
         {
           expectResults: {
-            Create: resultMissingFeatures,
+            Add: resultMissingFeatures,
             Edit: { disabled: true, disableDesc: '' },
             Delete: { disabled: true, disableDesc: '' }
           }
@@ -263,7 +264,7 @@ describe('HostsComponent', () => {
         {
           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 }
           }
@@ -271,7 +272,7 @@ describe('HostsComponent', () => {
         {
           selectRow: fakeHosts[1], // orchestrator host
           expectResults: {
-            Create: resultMissingFeatures,
+            Add: resultMissingFeatures,
             Edit: resultMissingFeatures,
             Delete: resultMissingFeatures
           }
index 8c559a748b05dff82aa9069d1a9871e27f2609be..1a3798b8ff9c0f1a77b30a5213fd48adabea28cd 100644 (file)
@@ -1,4 +1,4 @@
-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';
@@ -11,7 +11,7 @@ import { ConfirmationModalComponent } from '~/app/shared/components/confirmation
 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';
@@ -30,6 +30,7 @@ import { ModalService } from '~/app/shared/services/modal.service';
 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';
 
@@ -46,6 +47,8 @@ export class HostsComponent extends ListWithDetails implements OnInit {
   public servicesTpl: TemplateRef<any>;
   @ViewChild('maintenanceConfirmTpl', { static: true })
   maintenanceConfirmTpl: TemplateRef<any>;
+  @Input()
+  clusterCreation = false;
 
   permissions: Permissions;
   columns: Array<CdTableColumn> = [];
@@ -58,6 +61,8 @@ export class HostsComponent extends ListWithDetails implements OnInit {
   isExecuting = false;
   errorMessage: string;
   enableButton: boolean;
+  pageURL: string;
+  bsModalRef: NgbModalRef;
 
   icons = Icons;
 
@@ -67,7 +72,7 @@ export class HostsComponent extends ListWithDetails implements OnInit {
 
   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: [
@@ -80,7 +85,6 @@ export class HostsComponent extends ListWithDetails implements OnInit {
     private authStorageService: AuthStorageService,
     private hostService: HostService,
     private cephShortVersionPipe: CephShortVersionPipe,
-    private urlBuilder: URLBuilderService,
     private actionLabels: ActionLabelsI18n,
     private modalService: ModalService,
     private taskWrapper: TaskWrapperService,
@@ -91,13 +95,6 @@ export class HostsComponent extends ListWithDetails implements OnInit {
     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',
@@ -118,7 +115,10 @@ export class HostsComponent extends ListWithDetails implements OnInit {
         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,
@@ -126,12 +126,25 @@ export class HostsComponent extends ListWithDetails implements OnInit {
         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`,
@@ -141,6 +154,7 @@ export class HostsComponent extends ListWithDetails implements OnInit {
       {
         name: $localize`Services`,
         prop: 'services',
+        isHidden: this.clusterCreation,
         flexGrow: 3,
         cellTemplate: this.servicesTpl
       },
@@ -167,6 +181,7 @@ export class HostsComponent extends ListWithDetails implements OnInit {
       {
         name: $localize`Version`,
         prop: 'ceph_version',
+        isHidden: this.clusterCreation,
         flexGrow: 1,
         pipe: this.cephShortVersionPipe
       }
@@ -287,7 +302,7 @@ export class HostsComponent extends ListWithDetails implements OnInit {
   }
 
   getDisable(
-    action: 'create' | 'edit' | 'delete' | 'maintenance',
+    action: 'add' | 'edit' | 'delete' | 'maintenance',
     selection: CdTableSelection
   ): boolean | string {
     if (action === 'delete' || action === 'edit' || action === 'maintenance') {
index e85223c80e39ce950830871a5f9f6eddeb036f6f..93c9e9adcbbf0484aeed02dfb40905a5eb7bf6e1 100644 (file)
@@ -1,6 +1,7 @@
 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';
@@ -26,7 +27,8 @@ describe('OsdFlagsIndivModalComponent', () => {
       ReactiveFormsModule,
       SharedModule,
       ToastrModule.forRoot(),
-      NgbTooltipModule
+      NgbTooltipModule,
+      RouterTestingModule
     ],
     declarations: [OsdFlagsIndivModalComponent],
     providers: [NgbActiveModal]
index 3cbfab4ebaac35d674410189a7b196bd1ba8e1eb..fc02e9bdeeefbad495ddd56826f61b5b30f99f29 100644 (file)
@@ -53,6 +53,6 @@ describe('LoginComponent', () => {
     component.login();
 
     expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
-    expect(routerNavigateSpy).toHaveBeenCalledWith(['/create-cluster']);
+    expect(routerNavigateSpy).toHaveBeenCalledWith(['/expand-cluster']);
   });
 });
index 77bafd99c82e069bd4c91d135b0b618acd127493..a98548f94c766086934804eb8618ddc91a42ec22 100644 (file)
@@ -65,10 +65,10 @@ export class LoginComponent implements OnInit {
 
   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]);
     });
index ef8b423a3a70c55e583e331197e5b0852935b319..a6d0624d31885d162000fc39dbc6b09078f6ab9f 100644 (file)
@@ -45,6 +45,7 @@ import { SparklineComponent } from './sparkline/sparkline.component';
 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: [
@@ -93,7 +94,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
     Copy2ClipboardButtonComponent,
     DownloadButtonComponent,
     FormButtonPanelComponent,
-    MotdComponent
+    MotdComponent,
+    WizardComponent
   ],
   providers: [],
   exports: [
@@ -120,7 +122,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
     Copy2ClipboardButtonComponent,
     DownloadButtonComponent,
     FormButtonPanelComponent,
-    MotdComponent
+    MotdComponent,
+    WizardComponent
   ]
 })
 export class ComponentsModule {}
index 5cbd4f58c52fa4515e72ce129db817d96abc5e48..657e0d6053f89fbc5b2bf94dccb26a0746b4cb4d 100644 (file)
@@ -1,13 +1,19 @@
-<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">&times;</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">&times;</span>
+        </button>
+      </div>
 
-<ng-content select=".modal-content"></ng-content>
+      <ng-content select=".modal-content"></ng-content>
+    </div>
+  </div>
+</div>
index d3ee1ca2abd19feb7f58f5ef52dff2a2b9f61352..cf08bef10090d7713f8740131dd256a7711b64e0 100644 (file)
@@ -1,4 +1,6 @@
 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';
 
@@ -8,14 +10,18 @@ import { ModalComponent } from './modal.component';
 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();
   });
 
@@ -38,4 +44,11 @@ describe('ModalComponent', () => {
     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 } }]);
+  });
 });
index 730da6d62527b811a1056cdd79fc8351506d212a..25e06e62af188470e62f81ccc5f2e99ea924e21d 100644 (file)
@@ -1,4 +1,5 @@
 import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { Router } from '@angular/router';
 
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
@@ -10,6 +11,8 @@ 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.
@@ -17,8 +20,12 @@ export class ModalComponent {
   @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();
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html
new file mode 100644 (file)
index 0000000..25aa3e1
--- /dev/null
@@ -0,0 +1,19 @@
+<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss
new file mode 100644 (file)
index 0000000..80e3550
--- /dev/null
@@ -0,0 +1,30 @@
+@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;
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts
new file mode 100644 (file)
index 0000000..b42578f
--- /dev/null
@@ -0,0 +1,25 @@
+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();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts
new file mode 100644 (file)
index 0000000..d46aa48
--- /dev/null
@@ -0,0 +1,39 @@
+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();
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts
new file mode 100644 (file)
index 0000000..177feb4
--- /dev/null
@@ -0,0 +1,4 @@
+export interface WizardStepModel {
+  stepIndex: number;
+  isComplete: boolean;
+}
index c39bb0c26b6951601ae470cbba5e3d05709a37c6..44eb9bd30978a829b425535c70a630608be1c724 100644 (file)
@@ -114,9 +114,7 @@ export class TaskMessageService {
 
   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)
     ),
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts
new file mode 100644 (file)
index 0000000..47c2149
--- /dev/null
@@ -0,0 +1,16 @@
+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();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts
new file mode 100644 (file)
index 0000000..e0fb2be
--- /dev/null
@@ -0,0 +1,58 @@
+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;
+  }
+}
index cca9bd5d5d9d84723aee92be7cec83590c7d8ddc..3c6ddbf80c998b714a26c6ce8974499c5104f527 100644 (file)
 }
 
 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;
   }