]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: carbonize Setup multi-site replication wizard 69981/head
authorAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Tue, 7 Jul 2026 08:19:41 +0000 (13:49 +0530)
committerAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Thu, 23 Jul 2026 13:00:32 +0000 (18:30 +0530)
Fixes: https://tracker.ceph.com/issues/77976
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
23 files changed:
src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/multisite.po.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/services/service-form/service-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-flow-modal/rgw-multisite-sync-flow-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-pipe-modal/rgw-multisite-sync-pipe-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/multisite-wizard-steps.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/combo-box.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-configuration.model.ts
src/pybind/mgr/dashboard/frontend/src/styles.scss

index b180cffff06b1135afac2ba67cd877322c08f9b9..41f79c9fc47b4ccd549a3414b6d96896ea6c6b9d 100644 (file)
@@ -16,8 +16,8 @@ const pages = {
 };
 
 enum WizardSteps {
-  CreateRealmZonegroup = 'Create Realm & Zonegroup',
-  CreateZone = 'Create Zone',
+  CreateRealmZonegroup = 'Create realm & zonegroup',
+  CreateZone = 'Create zone',
   Review = 'Review'
 }
 
@@ -313,42 +313,52 @@ export class MultisitePageHelper extends PageHelper {
 
   @PageHelper.restrictTo(pages.wizard.url)
   replicationWizardExist() {
-    cy.get('cds-modal').then(() => {
-      cy.get('[data-testid=rgw-multisite-wizard-header]').should(
-        'contain.text',
-        'Set up Multi-site Replication'
-      );
-    });
+    cy.get('cds-modal').should('exist');
+    cy.get('[data-testid=rgw-multisite-wizard-header]').should(
+      'contain.text',
+      'Set up Multi-site Replication'
+    );
   }
 
-  @PageHelper.restrictTo(pages.index.url)
+  @PageHelper.restrictTo(pages.wizard.url)
   verifyWizardContents(step: Step) {
-    cy.get('cds-modal').then(() => {
-      this.gotoStep(step);
-      if (step === 'CreateRealmZonegroup') {
-        this.typeValueToField('realmName', 'test-realm');
-        this.typeValueToField('zonegroupName', 'test-zg');
-      } else if (step === 'CreateZone') {
-        this.typeValueToField('zoneName', 'test-zone');
-      } else {
-        this.gotoStep('Review');
-        cy.get('.form-group.row').then(() => {
-          cy.get('#realmName').invoke('text').should('eq', 'test-realm');
-          cy.get('#zonegroupName').invoke('text').should('eq', 'test-zg');
-          cy.get('#zoneName').invoke('text').should('eq', 'test-zone');
-        });
-      }
-    });
+    this.gotoStep(step);
+    if (step === 'CreateRealmZonegroup') {
+      this.typeValueToField('realmName', 'test-realm');
+      this.typeValueToField('zonegroupName', 'test-zg');
+    } else if (step === 'CreateZone') {
+      this.typeValueToField('zoneName', 'test-zone');
+    } else {
+      // step === 'Review': already navigated by gotoStep above
+      cy.get('.review-item').should('exist');
+      cy.get('.review-item')
+        .contains('dt', 'Realm name')
+        .siblings('dd')
+        .invoke('text')
+        .invoke('trim')
+        .should('eq', 'test-realm');
+      cy.get('.review-item')
+        .contains('dt', 'Zonegroup name')
+        .siblings('dd')
+        .invoke('text')
+        .invoke('trim')
+        .should('eq', 'test-zg');
+      cy.get('.review-item')
+        .contains('dt', 'Zone name')
+        .siblings('dd')
+        .invoke('text')
+        .invoke('trim')
+        .should('eq', 'test-zone');
+    }
   }
 
   typeValueToField(fieldID: string, value: string) {
-    cy.get(`#${fieldID}`).clear().type(value).should('have.value', value);
+    cy.get(`#${fieldID}`).should('be.visible').clear().type(value).should('have.value', value);
   }
 
   gotoStep(step: Step) {
-    cy.get('cd-wizard').then(() => {
-      cy.get('form').should('be.visible');
-      cy.get('button').contains(WizardSteps[step]).click();
-    });
+    cy.get('cd-rgw-multisite-wizard').should('exist');
+    cy.get('cds-progress-indicator').should('be.visible');
+    cy.get('cds-progress-indicator').contains('button', WizardSteps[step]).click();
   }
 }
index 6482c02a6285d4fdfa899e60f9cbc5b393a75ef9..91476f6b0511df513cbd476258022b7233a489c1 100644 (file)
@@ -72,7 +72,7 @@ export class HostFormComponent extends CdForm implements OnInit {
     this.hostService.getLabels().subscribe((resp: string[]) => {
       const uniqueLabels = new Set(resp.concat(this.hostService.predefinedLabels));
       this.labelsOption = Array.from(uniqueLabels).map((label) => {
-        return { name: label, content: label };
+        return { name: label, content: label, selected: false };
       });
     });
   }
index ef80a1ca4cbdcd3d2e0f228815679583b67bb1b8..e6fd70b271e4fa501fb77d6e2f933ac199f7dc00 100644 (file)
@@ -1463,7 +1463,13 @@ export class ServiceFormComponent extends CdForm implements OnInit {
   }
 
   enableRgwModule(): void {
-    this.mgrModuleService.updateModuleState('rgw', false, null, '', $localize`Enabled RGW Module`);
+    this.mgrModuleService.updateModuleState(
+      'rgw',
+      false,
+      undefined,
+      '',
+      $localize`Enabled RGW Module`
+    );
   }
 
   prePopulateId() {
index c36dadc796e66a07e6dc64f51b77f0312651f269..5fd728ef2a04f05b917a1104709b7e14300ce743 100755 (executable)
@@ -90,17 +90,18 @@ export class RgwMultisiteSyncFlowModalComponent extends BaseModal implements OnI
             zones.push(new SelectOption(false, zone.name, ''));
           });
           this.zones = [...zones].map((zone: { name: string }) => {
-            return { name: zone.name, content: zone.name };
+            return { name: zone.name, content: zone.name, selected: false };
           });
           if (this.editing) {
             // @TODO: Editing/deletion of directional flow not supported yet.
             // Integrate it once the backend supports it.
             if (this.groupType === FlowType.symmetrical) {
               this.zones = [...zones].map((zone: { name: string }) => {
-                if (this.flowSelectedRow.zones.includes(zone.name)) {
-                  return { name: zone.name, content: zone.name, selected: true };
-                }
-                return { name: zone.name, content: zone.name };
+                return {
+                  name: zone.name,
+                  content: zone.name,
+                  selected: this.flowSelectedRow.zones.includes(zone.name)
+                };
               });
               this.currentFormGroupContext.patchValue({ zones: this.zones });
             }
index cc70c9644d75ff675f9871220d76c5efd906c113..d7e9d424391ab89d24f2c975baf147aaebfe3234 100755 (executable)
@@ -111,24 +111,26 @@ export class RgwMultisiteSyncPipeModalComponent extends BaseModal implements OnI
             zones.push(new SelectOption(false, zone.name, ''));
           });
           this.sourceZones = JSON.parse(JSON.stringify(zones)).map((zone: { name: string }) => {
-            return { name: zone.name, content: zone.name };
+            return { name: zone.name, content: zone.name, selected: false };
           });
           this.destZones = JSON.parse(JSON.stringify(zones)).map((zone: { name: string }) => {
-            return { name: zone.name, content: zone.name };
+            return { name: zone.name, content: zone.name, selected: false };
           });
           if (this.editing) {
             this.pipeForm.get('pipe_id').disable();
             this.sourceZones = [...this.sourceZones].map((zone: { name: string }) => {
-              if (this.pipeSelectedRow.source.zones.includes(zone.name)) {
-                return { name: zone.name, content: zone.name, selected: true };
-              }
-              return { name: zone.name, content: zone.name };
+              return {
+                name: zone.name,
+                content: zone.name,
+                selected: this.pipeSelectedRow.source.zones.includes(zone.name)
+              };
             });
             this.destZones = [...this.destZones].map((zone: { name: string }) => {
-              if (this.pipeSelectedRow.dest.zones.includes(zone.name)) {
-                return { name: zone.name, content: zone.name, selected: true };
-              }
-              return { name: zone.name, content: zone.name };
+              return {
+                name: zone.name,
+                content: zone.name,
+                selected: this.pipeSelectedRow.dest.zones.includes(zone.name)
+              };
             });
             const availableDestZone: SelectOption[] = [];
             this.pipeSelectedRow.dest.zones.forEach((zone: string) => {
index d6437a3fa313f677a6984cabe090f9c61490ef23..7593b1e596fde166e3612e5aac485095eeb829e6 100644 (file)
@@ -1,7 +1,7 @@
 export enum StepTitles {
-  CreateRealmAndZonegroup = 'Create Realm & Zonegroup',
-  CreateZone = 'Create Zone',
-  SelectCluster = 'Select Cluster',
+  CreateRealmAndZonegroup = 'Create realm & zonegroup',
+  CreateZone = 'Create zone',
+  SelectCluster = 'Select cluster',
   Review = 'Review'
 }
 
index 9443b1eca337cae4b4398f8f8255706157871df1..2469a2691722bd435fa39a9a68cd6adb7ce275b8 100644 (file)
-<cds-modal
-  size="lg"
-  [open]="open"
-  [hasScrollingContent]="true"
-  (overlaySelected)="closeModal()"
+<cd-tearsheet
+  [steps]="stepTitles"
+  [title]="title"
+  [description]="description"
+  [submitButtonLabel]="showSubmitButtonLabel()"
+  [isSubmitLoading]="loading"
+  [hideInfluencer]="loading || setupCompleted"
+  [successIcon]="setupCompleted"
+  headerTestId="rgw-multisite-wizard-header"
+  (submitRequested)="onSubmit()"
+  (stepChanged)="onStepChanged($event)"
+  (validateStep)="onValidateStep($event)"
 >
-  <cds-modal-header (closeSelect)="closeModal()">
-    <h3
-      cdsModalHeaderHeading
-      data-testid="rgw-multisite-wizard-header"
-      i18n
+  <!-- Step 0: Realm / Zonegroup -->
+  <cd-tearsheet-step [stepValid]="step0Valid">
+    <form
+      [formGroup]="multisiteSetupForm"
+      #formDir="ngForm"
+      novalidate
     >
-      Set up Multi-site Replication
-    </h3>
-  </cds-modal-header>
-
-  <div cdsModalContent>
-    <div cdsRow>
-      <div
-        cdsCol
-        [columnNumbers]="{ lg: 2, md: 2, sm: 2 }"
-        class="indicator-wrapper"
+      <cd-alert-panel
+        type="info"
+        spacingClass="mb-3"
+        i18n
       >
-        <cd-wizard [stepsTitle]="stepTitles"></cd-wizard>
-      </div>
+        This wizard enables you to set up multi-site replication within your Ceph environment. If
+        you have already added another cluster to your multi-cluster setup, you can select that
+        cluster in the wizard to automate the replication process. If no additional cluster is
+        currently added, the wizard will guide you through creating the necessary realm, zonegroup,
+        and zone, and provide a realm token. This token can be used later to manually import into a
+        desired cluster to establish replication between the clusters.
+      </cd-alert-panel>
 
-      <div
-        cdsCol
-        [columnNumbers]="{ lg: 14, md: 14, sm: 14 }"
-      >
-        <form
-          [formGroup]="multisiteSetupForm"
-          #formDir="ngForm"
-          novalidate
-        >
-          <ng-container [ngSwitch]="currentStep?.stepIndex">
-            <div
-              *ngSwitchCase="0"
-              class="ms-5"
+      <!-- Config type radio -->
+      @if (showConfigType && isMultiClusterConfigured) {
+        <div class="form-item">
+          <cds-radio-group
+            formControlName="configType"
+            legend="Realm configuration mode"
+            i18n-legend
+            orientation="horizontal"
+            (change)="onConfigTypeChange($event)"
+          >
+            <cds-radio
+              value="newRealm"
+              i18n
+              >Create new realm/zonegroup/zone</cds-radio
+            >
+            <cds-radio
+              value="existingRealm"
+              i18n
+              >Select existing realm</cds-radio
             >
-              <cd-alert-panel
-                type="info"
-                spacingClass="mb-3"
+          </cds-radio-group>
+        </div>
+      }
+
+      <!-- Select existing realm -->
+      @if (
+        multisiteSetupForm.get('configType').value === 'existingRealm' &&
+        showConfigType &&
+        isMultiClusterConfigured
+      ) {
+        <div class="form-item">
+          <cds-select
+            formControlName="selectedRealm"
+            label="Select Realm"
+            i18n-label
+            id="selectedRealm"
+          >
+            @for (realm of realmList; track realm) {
+              <option [value]="realm">{{ realm }}</option>
+            }
+          </cds-select>
+        </div>
+      }
+
+      <!-- New realm fields -->
+      @if (multisiteSetupForm.get('configType').value === 'newRealm' || !showConfigType) {
+        <!-- Realm Name -->
+        <div class="form-item">
+          <cds-text-label
+            labelInputID="realmName"
+            i18n
+            cdRequiredField="Realm name"
+            helperText="Enter a unique name for the Realm. The Realm is a logical grouping of all your Zonegroups."
+            i18n-helperText
+            [invalid]="
+              multisiteSetupForm.controls.realmName.invalid &&
+              multisiteSetupForm.controls.realmName.dirty
+            "
+            [invalidText]="realmNameError"
+            >Realm name
+            <input
+              cdsText
+              type="text"
+              id="realmName"
+              formControlName="realmName"
+              [invalid]="
+                multisiteSetupForm.controls.realmName.invalid &&
+                multisiteSetupForm.controls.realmName.dirty
+              "
+            />
+          </cds-text-label>
+          <ng-template #realmNameError>
+            @if (multisiteSetupForm.showError('realmName', formDir, 'required')) {
+              <span
+                class="invalid-feedback"
+                i18n
+                >This field is required.</span
               >
-                This wizard enables you to set up multi-site replication within your Ceph
-                environment.If you have already added another cluster to your multi-cluster setup,
-                you can select that cluster in the wizard to automate the replication process.If no
-                additional cluster is currently added, the wizard will guide you through creating
-                the necessary realm, zonegroup, and zone, and provide a realm token.This token can
-                be used later to manually import into a desired cluster to establish replication
-                between the clusters.
-              </cd-alert-panel>
-              <div
-                class="form-group row"
-                *ngIf="showConfigType && isMultiClusterConfigured"
+            }
+            @if (multisiteSetupForm.showError('realmName', formDir, 'uniqueName')) {
+              <span
+                class="invalid-feedback"
+                i18n
+                >This realm name is already in use. Choose a unique name.</span
               >
-                <label
-                  class="cd-col-form-label required"
-                  for="configType"
+            }
+          </ng-template>
+        </div>
+
+        <!-- Zonegroup Name -->
+        <div class="form-item">
+          <cds-text-label
+            labelInputID="zonegroupName"
+            i18n
+            cdRequiredField="Zonegroup name"
+            helperText="Enter a name for the Zonegroup. Zonegroup will help you identify and manage the group of zones."
+            i18n-helperText
+            [invalid]="
+              multisiteSetupForm.controls.zonegroupName.invalid &&
+              multisiteSetupForm.controls.zonegroupName.dirty
+            "
+            [invalidText]="zonegroupNameError"
+            >Zonegroup name
+            <input
+              cdsText
+              type="text"
+              id="zonegroupName"
+              formControlName="zonegroupName"
+              [invalid]="
+                multisiteSetupForm.controls.zonegroupName.invalid &&
+                multisiteSetupForm.controls.zonegroupName.dirty
+              "
+            />
+          </cds-text-label>
+          <ng-template #zonegroupNameError>
+            @if (multisiteSetupForm.showError('zonegroupName', formDir, 'required')) {
+              <span
+                class="invalid-feedback"
+                i18n
+                >This field is required.</span
+              >
+            }
+            @if (multisiteSetupForm.showError('zonegroupName', formDir, 'uniqueName')) {
+              <span
+                class="invalid-feedback"
+                i18n
+                >This zonegroup name is already in use. Choose a unique name.</span
+              >
+            }
+          </ng-template>
+        </div>
+
+        <!-- Zonegroup Endpoints -->
+        <div class="form-item">
+          <cds-combo-box
+            label="Zonegroup endpoints"
+            i18n-label
+            type="multi"
+            selectionFeedback="top-after-reopen"
+            for="zonegroup_endpoints"
+            name="zonegroup_endpoints"
+            formControlName="zonegroup_endpoints"
+            placeholder="Select endpoints..."
+            i18n-placeholder
+            [appendInline]="true"
+            [items]="zonegroupEndpointsOptions"
+            itemValueKey="content"
+            id="zonegroup_endpoints"
+            cdDynamicInputCombobox
+            cdRequiredField="Zonegroup endpoints"
+            (updatedItems)="zonegroupEndpointsOptions = $event"
+            [invalid]="
+              multisiteSetupForm.controls.zonegroup_endpoints.invalid &&
+              multisiteSetupForm.controls.zonegroup_endpoints.dirty
+            "
+            [invalidText]="zonegroupEndpointsError"
+            i18n
+          >
+            <cds-dropdown-list></cds-dropdown-list>
+          </cds-combo-box>
+          <ng-template #zonegroupEndpointsError>
+            <span i18n>This field is required.</span>
+          </ng-template>
+          <cd-help-text i18n
+            >Select the endpoints for the Zonegroup. Endpoints are the URLs or IP addresses from
+            which the rgw gateways in that zonegroup can be accessed. You can select multiple
+            endpoints in case you have multiple rgw gateways in a zonegroup.</cd-help-text
+          >
+        </div>
+      }
+    </form>
+  </cd-tearsheet-step>
+
+  <!-- Step 1: Zone / existing-realm replication -->
+  <cd-tearsheet-step [stepValid]="step1Valid">
+    <form
+      [formGroup]="multisiteSetupForm"
+      #formDir="ngForm"
+      novalidate
+    >
+      @if (multisiteSetupForm.get('configType').value === 'newRealm') {
+        <div>
+          <!-- Zone Name -->
+          <div class="form-item">
+            <cds-text-label
+              labelInputID="zoneName"
+              i18n
+              cdRequiredField="Zone name"
+              helperText="Enter a unique name for the Zone. A Zone represents a distinct data center or geographical location within a Zonegroup."
+              i18n-helperText
+              [invalid]="
+                multisiteSetupForm.controls.zoneName.invalid &&
+                multisiteSetupForm.controls.zoneName.dirty
+              "
+              [invalidText]="zoneNameError"
+              >Zone name
+              <input
+                cdsText
+                type="text"
+                id="zoneName"
+                formControlName="zoneName"
+                [invalid]="
+                  multisiteSetupForm.controls.zoneName.invalid &&
+                  multisiteSetupForm.controls.zoneName.dirty
+                "
+              />
+            </cds-text-label>
+            <ng-template #zoneNameError>
+              @if (multisiteSetupForm.showError('zoneName', formDir, 'required')) {
+                <span
+                  class="invalid-feedback"
                   i18n
-                  >Realm configuration mode</label
+                  >This field is required.</span
                 >
-                <div class="col-md-auto custom-checkbox form-check-inline ms-3">
-                  <input
-                    class="form-check-input"
-                    formControlName="configType"
-                    id="newRealm"
-                    value="newRealm"
-                    (change)="onConfigTypeChange()"
-                    type="radio"
-                  />
-                  <label
-                    class="custom-check-label"
-                    for="newRealm"
-                    i18n
-                    >Create new realm/zonegroup/zone</label
-                  >
-                </div>
-                <div class="col-md-auto custom-checkbox form-check-inline">
-                  <input
-                    class="form-check-input"
-                    formControlName="configType"
-                    id="existingRealm"
-                    type="radio"
-                    (change)="onConfigTypeChange()"
-                    value="existingRealm"
-                  />
-                  <label
-                    class="custom-check-label"
-                    for="existingRealm"
-                    i18n
-                    >Select existing realm</label
-                  >
-                </div>
-              </div>
-              <div
-                class="form-group row"
-                *ngIf="
-                  multisiteSetupForm.get('configType').value === 'existingRealm' &&
-                  showConfigType &&
-                  isMultiClusterConfigured
-                "
-              >
-                <label
-                  class="cd-col-form-label"
-                  for="selectedRealm"
+              }
+              @if (multisiteSetupForm.showError('zoneName', formDir, 'uniqueName')) {
+                <span
+                  class="invalid-feedback"
                   i18n
-                  >Select Realm</label
+                  >This zone name is already in use. Choose a unique name.</span
                 >
-                <div class="cd-col-form-input">
-                  <select
-                    class="form-select"
-                    id="selectedRealm"
-                    formControlName="selectedRealm"
-                  >
-                    <option
-                      *ngFor="let realm of realmList"
-                      [value]="realm"
-                    >
-                      {{ realm }}
-                    </option>
-                  </select>
-                </div>
-              </div>
-              <div
-                *ngIf="multisiteSetupForm.get('configType').value === 'newRealm' || !showConfigType"
-              >
-                <div class="form-group row">
-                  <label
-                    class="cd-col-form-label required"
-                    for="realmName"
-                    i18n
-                    >Realm Name</label
-                  >
-                  <div class="cd-col-form-input">
-                    <input
-                      class="form-control"
-                      type="text"
-                      id="realmName"
-                      formControlName="realmName"
-                    />
-                    <cd-help-text>
-                      <span i18n
-                        >Enter a unique name for the Realm. The Realm is a logical grouping of all
-                        your Zonegroups.</span
-                      >
-                    </cd-help-text>
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('realmName', formDir, 'required')"
-                      i18n
-                      >This field is required.</span
-                    >
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('realmName', formDir, 'uniqueName')"
-                      i18n
-                      >This realm name is already in use. Choose a unique name.</span
-                    >
-                  </div>
-                </div>
-                <div class="form-group row">
-                  <label
-                    class="cd-col-form-label required"
-                    for="zonegroupName"
-                    i18n
-                    >Zonegroup Name</label
-                  >
-                  <div class="cd-col-form-input">
-                    <input
-                      class="form-control"
-                      type="text"
-                      id="zonegroupName"
-                      formControlName="zonegroupName"
-                    />
-                    <cd-help-text>
-                      <span i18n
-                        >Enter a name for the Zonegroup. Zonegroup will help you identify and manage
-                        the group of zones.</span
-                      >
-                    </cd-help-text>
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('zonegroupName', formDir, 'required')"
-                      i18n
-                      >This field is required.</span
-                    >
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('zonegroupName', formDir, 'uniqueName')"
-                      i18n
-                      >This zonegroup name is already in use. Choose a unique name.</span
-                    >
-                  </div>
-                </div>
-                <div class="form-group row">
-                  <label
-                    class="cd-col-form-label required"
-                    for="zonegroup_endpoints"
-                    i18n
-                    >Zonegroup Endpoints</label
-                  >
-                  <div class="cd-col-form-input">
-                    <cd-select-badges
-                      id="zonegroup_endpoints"
-                      [data]="rgwEndpoints.value"
-                      [options]="rgwEndpoints.options"
-                      [customBadges]="true"
-                    >
-                    </cd-select-badges>
-                    <cd-help-text>
-                      <span i18n
-                        >Select the endpoints for the Zonegroup. Endpoints are the URLs or IP
-                        addresses from which the rgw gateways in that zonegroup can be accessed. You
-                        can select multiple endpoints in case you have multiple rgw gateways in a
-                        zonegroup</span
-                      >
-                    </cd-help-text>
-                  </div>
-                </div>
-              </div>
-            </div>
-            <ng-container *ngSwitchCase="1">
-              <div
-                *ngIf="multisiteSetupForm.get('configType').value === 'newRealm'"
-                class="ms-5"
-              >
-                <div class="form-group row">
-                  <label
-                    class="cd-col-form-label required"
-                    for="zonegroupName"
-                    i18n
-                    >Zone Name</label
-                  >
-                  <div class="cd-col-form-input">
-                    <input
-                      class="form-control"
-                      type="text"
-                      id="zoneName"
-                      formControlName="zoneName"
-                    />
-                    <cd-help-text>
-                      <span i18n
-                        >Enter a unique name for the Zone. A Zone represents a distinct data center
-                        or geographical location within a Zonegroup.</span
-                      >
-                    </cd-help-text>
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('zoneName', formDir, 'required')"
-                      i18n
-                      >This field is required.</span
-                    >
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('zoneName', formDir, 'uniqueName')"
-                      i18n
-                      >This zone name is already in use. Choose a unique name.</span
-                    >
-                  </div>
-                </div>
-                <div class="form-group row">
-                  <label
-                    class="cd-col-form-label required"
-                    for="zone_endpoints"
-                    i18n
-                    >Zone Endpoints</label
-                  >
-                  <div class="cd-col-form-input">
-                    <cd-select-badges
-                      id="zone_endpoints"
-                      [data]="rgwEndpoints.value"
-                      [options]="rgwEndpoints.options"
-                      [customBadges]="true"
-                    ></cd-select-badges>
-                    <cd-help-text>
-                      <span i18n
-                        >Select the endpoints for the Zone. Endpoints are the URLs or IP addresses
-                        from which the rgw gateways in that zone can be accessed. You can select
-                        multiple endpoints in case you have multiple rgw gateways in a zone</span
-                      >
-                    </cd-help-text>
-                  </div>
-                </div>
-                <div class="form-group row">
-                  <label
-                    class="cd-col-form-label required"
-                    for="username"
-                    i18n
-                    >Username</label
-                  >
-                  <div class="cd-col-form-input">
-                    <input
-                      class="form-control"
-                      type="text"
-                      id="username"
-                      formControlName="username"
-                      ngbTooltip="White spaces at the beginning and end will be trimmed"
-                      i18n-ngbTooltip
-                      cdTrim
-                    />
-                    <cd-help-text>
-                      <span i18n>Specify the username for the system user.</span>
-                    </cd-help-text>
-                    <cd-alert-panel
-                      type="info"
-                      [showTitle]="false"
-                    >
-                      <span i18n
-                        >This user will be created automatically as part of the process, and it will
-                        have the necessary permissions to manage and synchronize resources across
-                        zones.</span
-                      >
-                    </cd-alert-panel>
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('username', formDir, 'required')"
-                      i18n
-                      >This field is required.</span
-                    >
-                    <span
-                      class="invalid-feedback"
-                      *ngIf="multisiteSetupForm.showError('username', formDir, 'notUnique')"
-                      i18n
-                      >This username is already in use. Choose a unique name.</span
-                    >
-                  </div>
-                </div>
-              </div>
-              <div
-                *ngIf="
-                  isMultiClusterConfigured &&
-                  multisiteSetupForm.get('configType').value === 'existingRealm'
-                "
-                class="ms-5"
-              >
-                <ng-container *ngTemplateOutlet="replicationTemplate"></ng-container>
-              </div>
-            </ng-container>
-            <ng-container *ngSwitchCase="2">
-              <div
-                *ngIf="
-                  multisiteSetupForm.get('configType').value === 'newRealm' &&
-                  !isMultiClusterConfigured
-                "
-                class="ms-5"
-              >
-                <ng-container *ngIf="loading; else nonMultiClusterFinal">
-                  <ng-container *ngTemplateOutlet="progressTemplate"></ng-container>
-                </ng-container>
-              </div>
-              <ng-template #nonMultiClusterFinal>
-                <ng-container *ngIf="!setupCompleted; else exportTokenTemplate">
-                  <ng-container *ngTemplateOutlet="reviewTemplate"></ng-container>
-                </ng-container>
-              </ng-template>
-              <div
-                *ngIf="
-                  multisiteSetupForm.get('configType').value === 'newRealm' &&
-                  isMultiClusterConfigured
-                "
-                class="ms-5"
-              >
-                <ng-container *ngTemplateOutlet="replicationTemplate"></ng-container>
-              </div>
-              <div
-                *ngIf="
-                  multisiteSetupForm.get('configType').value === 'existingRealm' &&
-                  isMultiClusterConfigured
+              }
+            </ng-template>
+          </div>
+
+          <!-- Zone Endpoints -->
+          <div class="form-item">
+            <cds-combo-box
+              label="Zone endpoints"
+              i18n-label
+              type="multi"
+              selectionFeedback="top-after-reopen"
+              for="zone_endpoints"
+              name="zone_endpoints"
+              formControlName="zone_endpoints"
+              placeholder="Select endpoints..."
+              i18n-placeholder
+              [appendInline]="true"
+              [items]="zoneEndpointsOptions"
+              itemValueKey="content"
+              id="zone_endpoints"
+              cdDynamicInputCombobox
+              cdRequiredField="Zone endpoints"
+              (updatedItems)="zoneEndpointsOptions = $event"
+              [invalid]="
+                multisiteSetupForm.controls.zone_endpoints.invalid &&
+                multisiteSetupForm.controls.zone_endpoints.dirty
+              "
+              [invalidText]="zoneEndpointsError"
+              i18n
+            >
+              <cds-dropdown-list></cds-dropdown-list>
+            </cds-combo-box>
+            <ng-template #zoneEndpointsError>
+              <span i18n>This field is required.</span>
+            </ng-template>
+            <cd-help-text i18n
+              >Select the endpoints for the Zone. Endpoints are the URLs or IP addresses from which
+              the rgw gateways in that zone can be accessed. You can select multiple endpoints in
+              case you have multiple rgw gateways in a zone.</cd-help-text
+            >
+          </div>
+
+          <!-- Username -->
+          <div class="form-item">
+            <cds-text-label
+              labelInputID="username"
+              i18n
+              cdRequiredField="Username"
+              helperText="Specify the username for the system user."
+              i18n-helperText
+              [invalid]="
+                multisiteSetupForm.controls.username.invalid &&
+                multisiteSetupForm.controls.username.dirty
+              "
+              [invalidText]="usernameError"
+              >Username
+              <input
+                cdsText
+                type="text"
+                id="username"
+                formControlName="username"
+                cdTrim
+                [invalid]="
+                  multisiteSetupForm.controls.username.invalid &&
+                  multisiteSetupForm.controls.username.dirty
                 "
-                class="ms-5"
-              >
-                <ng-container *ngIf="!loading; else loadingTemplate">
-                  <ng-container *ngIf="!setupCompleted; else progressCompleteTemplate">
-                    <ng-container *ngTemplateOutlet="reviewTemplate"></ng-container>
-                  </ng-container>
-                </ng-container>
-              </div>
-            </ng-container>
-            <ng-template #replicationTemplate>
-              <div class="form-group row">
-                <label
-                  class="cd-col-form-label required"
-                  for="cluster"
+              />
+            </cds-text-label>
+            <ng-template #usernameError>
+              @if (multisiteSetupForm.showError('username', formDir, 'required')) {
+                <span
+                  class="invalid-feedback"
                   i18n
-                  >Replication Cluster</label
+                  >This field is required.</span
                 >
-                <div class="cd-col-form-input">
-                  <select
-                    class="form-select"
-                    id="cluster"
-                    [(ngModel)]="selectedCluster"
-                    formControlName="cluster"
-                    name="cluster"
-                  >
-                    <option
-                      *ngFor="let cluster_detail of clusterDetailsArray"
-                      [value]="cluster_detail.name"
-                    >
-                      {{ cluster_detail.cluster_alias }} - {{ cluster_detail.name }}
-                    </option>
-                  </select>
-                  <cd-help-text>
-                    <span i18n
-                      >Choose the cluster where you want to apply this multisite configuration. The
-                      selected cluster will integrate the defined Realm, Zonegroup, and Zones,
-                      enabling data synchronization and management across the multisite setup.</span
-                    >
-                  </cd-help-text>
-                  <cd-alert-panel
-                    type="info"
-                    [showTitle]="false"
-                  >
-                    <span i18n
-                      >Before submitting this form, please verify that the selected cluster has an
-                      active RGW (Rados Gateway) service running.</span
-                    >
-                  </cd-alert-panel>
-                </div>
-              </div>
-              <div class="form-group row">
-                <label
-                  class="cd-col-form-label required"
-                  for="replicationZoneName"
+              }
+              @if (multisiteSetupForm.showError('username', formDir, 'notUnique')) {
+                <span
+                  class="invalid-feedback"
                   i18n
-                  >Replication Zone Name</label
+                  >This username is already in use. Choose a unique name.</span
                 >
-                <div class="cd-col-form-input">
-                  <input
-                    class="form-control"
-                    type="text"
-                    id="replicationZoneName"
-                    name="replicationZoneName"
-                    formControlName="replicationZoneName"
-                  />
-                  <cd-help-text>
-                    <span i18n
-                      >Replication zone represents the zone to be created in the replication cluster
-                      where your data will be replicated.</span
-                    >
-                  </cd-help-text>
-                  <span
-                    class="invalid-feedback"
-                    *ngIf="multisiteSetupForm.showError('replicationZoneName', formDir, 'required')"
-                    i18n
-                    >This field is required.</span
-                  >
-                </div>
-              </div>
-              <div class="form-group row">
-                <div class="cd-col-form-offset">
-                  <input
-                    type="checkbox"
-                    formControlName="secondary_archive_zone"
-                    id="secondary_archive_zone"
-                  />
-                  <label
-                    for="secondary_archive_zone"
-                    class="custom-control-label cds-ml-3"
-                    i18n
-                    >Archive</label
-                  >
-                  <cd-help-text>
-                    <span i18n
-                      >Enable archival storage to keep all object versions and protect data from
-                      deletion or corruption.</span
-                    >
-                  </cd-help-text>
-                </div>
-              </div>
+              }
             </ng-template>
-            <div
-              *ngSwitchCase="3"
-              class="ms-5"
+            <cd-alert-panel
+              type="info"
+              [showTitle]="false"
+              spacingClass="mt-3"
             >
-              <div *ngIf="isMultiClusterConfigured">
-                <ng-container *ngIf="!loading; else loadingTemplate">
-                  <ng-container *ngIf="!setupCompleted; else progressCompleteTemplate">
-                    <ng-container *ngTemplateOutlet="reviewTemplate"></ng-container>
-                  </ng-container>
-                </ng-container>
-              </div>
-            </div>
-          </ng-container>
-        </form>
-      </div>
-    </div>
-  </div>
-  <cds-modal-footer>
-    <button
-      cdsButton="secondary"
-      name="skip-cluster-selection"
-      aria-label="Skip"
-      (click)="onSkip()"
-      type="button"
-      *ngIf="
-        stepTitles[currentStep.stepIndex]['label'] === 'Select Cluster' &&
-        multisiteSetupForm.get('configType').value === 'newRealm'
-      "
-      i18n
-    >
-      Skip
-    </button>
-    <button
-      cdsButton="secondary"
-      (click)="onPreviousStep()"
-      [attr.aria-label]="showCancelButtonLabel()"
-      type="button"
-      i18n
-    >
-      {{ showCancelButtonLabel() }}
-    </button>
-    <button
-      cdsButton="primary"
-      (click)="onNextStep()"
-      aria-label="Next"
-      [disabled]="loading"
-      type="button"
-      i18n
-    >
-      {{ showSubmitButtonLabel() }}
-      <cds-loading
-        [isActive]="loading"
-        [overlay]="false"
-        size="sm"
-        *ngIf="loading"
+              <span i18n
+                >This user will be created automatically as part of the process, and it will have
+                the necessary permissions to manage and synchronize resources across zones.</span
+              >
+            </cd-alert-panel>
+          </div>
+        </div>
+      }
+      @if (
+        isMultiClusterConfigured && multisiteSetupForm.get('configType').value === 'existingRealm'
+      ) {
+        <div>
+          <!-- Replication Cluster -->
+          <div class="form-item">
+            <cds-select
+              formControlName="cluster"
+              label="Replication cluster"
+              i18n-label
+              id="cluster"
+              cdRequiredField="Replication cluster"
+              helperText="Choose the cluster where you want to apply this multisite configuration. The selected cluster will integrate the defined Realm, Zonegroup, and Zones, enabling data synchronization and management across the multisite setup."
+              i18n-helperText
+              [invalid]="
+                multisiteSetupForm.controls.cluster.invalid &&
+                multisiteSetupForm.controls.cluster.dirty
+              "
+              [invalidText]="clusterError1"
+            >
+              @for (cluster_detail of clusterDetailsArray; track cluster_detail.name) {
+                <option [value]="cluster_detail.name">
+                  {{ cluster_detail.cluster_alias }} - {{ cluster_detail.name }}
+                </option>
+              }
+            </cds-select>
+            <ng-template #clusterError1>
+              @if (
+                multisiteSetupForm.controls.cluster.invalid &&
+                multisiteSetupForm.controls.cluster.dirty
+              ) {
+                <span
+                  class="invalid-feedback"
+                  i18n
+                  >This field is required.</span
+                >
+              }
+            </ng-template>
+          </div>
+          <!-- Replication Zone Name -->
+          <div class="form-item">
+            <cds-text-label
+              labelInputID="replicationZoneName"
+              i18n
+              cdRequiredField="Replication zone name"
+              helperText="Replication zone represents the zone to be created in the replication cluster where your data will be replicated."
+              i18n-helperText
+              [invalid]="
+                multisiteSetupForm.controls.replicationZoneName.invalid &&
+                multisiteSetupForm.controls.replicationZoneName.dirty
+              "
+              [invalidText]="replicationZoneError1"
+              >Replication zone name
+              <input
+                cdsText
+                type="text"
+                id="replicationZoneName"
+                formControlName="replicationZoneName"
+                [invalid]="
+                  multisiteSetupForm.controls.replicationZoneName.invalid &&
+                  multisiteSetupForm.controls.replicationZoneName.dirty
+                "
+              />
+            </cds-text-label>
+            <ng-template #replicationZoneError1>
+              @if (
+                multisiteSetupForm.controls.replicationZoneName.invalid &&
+                multisiteSetupForm.controls.replicationZoneName.dirty
+              ) {
+                <span
+                  class="invalid-feedback"
+                  i18n
+                  >This field is required.</span
+                >
+              }
+            </ng-template>
+          </div>
+          <!-- Archive checkbox -->
+          <div class="form-item">
+            <cds-checkbox
+              formControlName="secondary_archive_zone"
+              id="secondary_archive_zone"
+              ><ng-container i18n>Archive</ng-container>
+              <cd-help-text>
+                <span i18n
+                  >Enable archival storage to keep all object versions and protect data from
+                  deletion or corruption.</span
+                >
+              </cd-help-text>
+            </cds-checkbox>
+          </div>
+        </div>
+      }
+    </form>
+  </cd-tearsheet-step>
+
+  <!-- Step 2: Select cluster (multi-cluster + newRealm) -->
+  @if (stepTitles.length > 3) {
+    <cd-tearsheet-step [stepValid]="step2Valid">
+      <form
+        [formGroup]="multisiteSetupForm"
+        novalidate
       >
-      </cds-loading>
-    </button>
-  </cds-modal-footer>
-</cds-modal>
+        <cd-alert-panel
+          type="info"
+          [showTitle]="false"
+          spacingClass="mb-3"
+        >
+          <span i18n
+            >Select a cluster to automatically configure replication now, or skip this step to get a
+            realm token you can use to set up replication manually later.</span
+          >
+        </cd-alert-panel>
+        <!-- Replication Cluster -->
+        <div class="form-item">
+          <cds-select
+            formControlName="cluster"
+            label="Replication cluster"
+            i18n-label
+            id="cluster"
+            cdRequiredField="Replication cluster"
+            helperText="Choose the cluster where you want to apply this multisite configuration. The selected cluster will integrate the defined Realm, Zonegroup, and Zones, enabling data synchronization and management across the multisite setup."
+            i18n-helperText
+            [invalid]="
+              multisiteSetupForm.controls.cluster.invalid &&
+              multisiteSetupForm.controls.cluster.dirty
+            "
+            [invalidText]="clusterError2"
+          >
+            @for (cluster_detail of clusterDetailsArray; track cluster_detail.name) {
+              <option [value]="cluster_detail.name">
+                {{ cluster_detail.cluster_alias }} - {{ cluster_detail.name }}
+              </option>
+            }
+          </cds-select>
+          <ng-template #clusterError2>
+            @if (
+              multisiteSetupForm.controls.cluster.invalid &&
+              multisiteSetupForm.controls.cluster.dirty
+            ) {
+              <span
+                class="invalid-feedback"
+                i18n
+                >This field is required.</span
+              >
+            }
+          </ng-template>
+        </div>
+        <!-- Replication Zone Name -->
+        <div class="form-item">
+          <cds-text-label
+            labelInputID="replicationZoneName"
+            i18n
+            cdRequiredField="Replication zone name"
+            helperText="Replication zone represents the zone to be created in the replication cluster where your data will be replicated."
+            i18n-helperText
+            [invalid]="
+              multisiteSetupForm.controls.replicationZoneName.invalid &&
+              multisiteSetupForm.controls.replicationZoneName.dirty
+            "
+            [invalidText]="replicationZoneError2"
+            >Replication zone name
+            <input
+              cdsText
+              type="text"
+              id="replicationZoneName"
+              formControlName="replicationZoneName"
+              [invalid]="
+                multisiteSetupForm.controls.replicationZoneName.invalid &&
+                multisiteSetupForm.controls.replicationZoneName.dirty
+              "
+            />
+          </cds-text-label>
+          <ng-template #replicationZoneError2>
+            @if (
+              multisiteSetupForm.controls.replicationZoneName.invalid &&
+              multisiteSetupForm.controls.replicationZoneName.dirty
+            ) {
+              <span
+                class="invalid-feedback"
+                i18n
+                >This field is required.</span
+              >
+            }
+          </ng-template>
+        </div>
+        <!-- Archive checkbox -->
+        <div class="form-item">
+          <cds-checkbox
+            formControlName="secondary_archive_zone"
+            id="secondary_archive_zone"
+            ><ng-container i18n>Archive</ng-container>
+            <cd-help-text>
+              <span i18n
+                >Enable archival storage to keep all object versions and protect data from deletion
+                or corruption.</span
+              >
+            </cd-help-text>
+          </cds-checkbox>
+        </div>
+        <!-- Skip option -->
+        <div class="skip-step">
+          <button
+            cdsButton="ghost"
+            type="button"
+            size="sm"
+            (click)="skipSelectCluster()"
+            i18n
+          >
+            Skip this step — export token instead
+          </button>
+        </div>
+      </form>
+    </cd-tearsheet-step>
+  }
 
-<ng-template #nonMultiClusterTemplate>
-  <ng-container *ngIf="!loading; else loadingTemplate">
-    <ng-container *ngIf="!setupCompleted; else exportTokenTemplate">
-      <ng-container *ngTemplateOutlet="reviewTemplate"></ng-container>
-    </ng-container>
-  </ng-container>
-</ng-template>
+  <!-- Final step: Review / Progress / Result -->
+  <cd-tearsheet-step>
+    @if (!loading) {
+      @if (!setupCompleted) {
+        <ng-container *ngTemplateOutlet="reviewTemplate"></ng-container>
+      } @else {
+        <ng-container *ngTemplateOutlet="completedTemplate"></ng-container>
+      }
+    } @else {
+      <ng-container *ngTemplateOutlet="loadingTemplate"></ng-container>
+    }
+  </cd-tearsheet-step>
+</cd-tearsheet>
 
 <ng-template #loadingTemplate>
-  <ng-container *ngTemplateOutlet="progressTemplate"></ng-container>
-</ng-template>
-
-<ng-template #progressCompleteTemplate>
-  <div *ngIf="isMultiClusterConfigured && !stepsToSkip['Select Cluster']; else exportTokenTemplate">
-    <div
-      class="text-center text-success"
-      i18n
+  <div class="cds-mt-5">
+    <cds-progress-bar
+      [value]="executingTask?.progress"
+      [label]="
+        (executingTask?.name?.replace('progress/Multisite-Setup:', '') ?? '')
+          .split('||')[0]
+          .trim() +
+        ' (' +
+        (executingTask?.progress ?? 0) +
+        '%)'
+      "
+      [helperText]="
+        (executingTask?.name?.replace('progress/Multisite-Setup:', '') ?? '')
+          .split('||')[1]
+          ?.trim() ?? ''
+      "
+      [max]="100"
+      status="active"
     >
-      Multi-site replication setup is complete.
-    </div>
+    </cds-progress-bar>
   </div>
 </ng-template>
 
-<ng-template #progressTemplate>
-  <cd-progress
-    [value]="executingTask?.progress"
-    [description]="
-      executingTask?.name?.replace('progress/Multisite-Setup:', '')?.split('||')[0]?.trim()
-    "
-    [subDescription]="
-      executingTask?.name?.replace('progress/Multisite-Setup:', '')?.split('||')[1]?.trim()
-    "
-  >
-  </cd-progress>
+<ng-template #completedTemplate>
+  @if (isMultiClusterConfigured && !stepsToSkip['Select cluster']) {
+    <dl class="review-list review-list-end">
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Realm
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">{{ completionSummary?.realm }}</dd>
+      </div>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Secondary cluster
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ completionSummary?.secondaryCluster }}
+        </dd>
+      </div>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Secondary zone
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ completionSummary?.secondaryZone }}
+        </dd>
+      </div>
+    </dl>
+  } @else {
+    <ng-container *ngTemplateOutlet="exportTokenTemplate"></ng-container>
+  }
 </ng-template>
 
 <ng-template #exportTokenTemplate>
   @for (realminfo of realms; track realminfo; let i = $index) {
-    <div class="form-item">
-      <cds-text-label
-        [labelInputID]="'wizard-export-realm-' + i"
-        i18n
-        helperText="Name of the realm that will be involved in replication."
-        i18n-helperText
-      >
-        Realm Name
-        <input
-          cdsText
-          readonly
-          type="text"
-          [id]="'wizard-export-realm-' + i"
-          [value]="realminfo.realm"
-        />
-      </cds-text-label>
-    </div>
-
-    <div class="form-item form-item-append">
-      <cds-text-label
-        [labelInputID]="'wizard-export-token-' + i"
-        i18n
-        helperText="This field displays the token needed to import the multisite configuration into a secondary cluster. Copy this token securely and use it on the secondary cluster to replicate the current multisite setup. Ensure that the token is handled securely to prevent unauthorized access."
-        i18n-helperText
-      >
-        Token
-        <input
-          cdsText
-          readonly
-          type="text"
-          [id]="'wizard-export-token-' + i"
-          [value]="realminfo.token"
-        />
-      </cds-text-label>
-      <cd-copy-2-clipboard-button
-        class="cds-mt-6"
-        [source]="realminfo.token"
-        [byId]="false"
-        size="md"
-      >
-      </cd-copy-2-clipboard-button>
-    </div>
-
+    <dl class="review-list">
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Realm
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">{{ realminfo.realm }}</dd>
+      </div>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Realm token
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          <cds-password-label [labelInputID]="'wizard-token-' + i">
+            <input
+              cdsPassword
+              readonly
+              type="password"
+              [id]="'wizard-token-' + i"
+              [value]="realminfo.token"
+              aria-label="Realm token"
+              i18n-aria-label
+            />
+          </cds-password-label>
+        </dd>
+      </div>
+    </dl>
+    <cd-copy-2-clipboard-button
+      [source]="realminfo.token"
+      [byId]="false"
+      text="Copy token"
+      i18n-text
+      size="md"
+    >
+    </cd-copy-2-clipboard-button>
     @if (i < realms.length - 1) {
       <hr />
     }
   }
+  <cd-alert-panel
+    type="info"
+    [showTitle]="false"
+    spacingClass="mt-5"
+  >
+    <span i18n
+      >This token is required to establish replication with another cluster. Store it securely until
+      it has been imported.</span
+    >
+  </cd-alert-panel>
 </ng-template>
 
 <ng-template #reviewTemplate>
   <cd-alert-panel
     type="warning"
     [showTitle]="false"
+    spacingClass="mb-3"
   >
     <span i18n>
       During the automation process, the RGW module will be enabled on both the source and target
       seconds) on each cluster.
     </span>
   </cd-alert-panel>
-  <ng-container [ngSwitch]="multisiteSetupForm.get('configType').value">
-    <ng-container *ngSwitchCase="'newRealm'">
-      <ng-container *ngTemplateOutlet="newRealmInfo"></ng-container>
-      <ng-container *ngTemplateOutlet="replicationInfo"></ng-container>
-    </ng-container>
-    <ng-container *ngSwitchCase="'existingRealm'">
-      <ng-container *ngTemplateOutlet="existingRealmInfo"></ng-container>
-      <ng-container *ngTemplateOutlet="replicationInfo"></ng-container>
-    </ng-container>
-  </ng-container>
-</ng-template>
-
-<ng-template #newRealmInfo>
-  <div class="form-group row">
-    <legend
-      class="cd-col-form-label"
-      i18n
-    >
-      Realm Name:
-    </legend>
-    <div class="cd-col-form-input mt-2 text-muted">
-      <b id="realmName">{{ multisiteSetupForm.get('realmName').value }}</b>
-    </div>
-  </div>
-  <div class="form-group row">
-    <legend
-      class="cd-col-form-label"
-      i18n
-    >
-      Zonegroup Name:
-    </legend>
-    <div class="cd-col-form-input mt-2 text-muted">
-      <b id="zonegroupName">{{ multisiteSetupForm.get('zonegroupName').value }}</b>
-    </div>
-  </div>
-  <div class="form-group row">
-    <legend
-      class="cd-col-form-label"
-      i18n
-    >
-      Zonegroup Endpoints:
-    </legend>
-    <div class="cd-col-form-input mt-2 text-muted">
-      <b>{{ rgwEndpoints.value.join(', ') }}</b>
-    </div>
-  </div>
-  <div class="form-group row">
-    <legend
-      class="cd-col-form-label"
-      i18n
-    >
-      Zone Name:
-    </legend>
-    <div class="cd-col-form-input mt-2 text-muted">
-      <b id="zoneName">{{ multisiteSetupForm.get('zoneName').value }}</b>
-    </div>
-  </div>
-  <div class="form-group row">
-    <legend
-      class="cd-col-form-label"
-      i18n
-    >
-      Zone Endpoints:
-    </legend>
-    <div class="cd-col-form-input mt-2 text-muted">
-      <b>{{ rgwEndpoints.value.join(', ') }}</b>
-    </div>
-  </div>
-  <div class="form-group row">
-    <legend
-      class="cd-col-form-label"
-      i18n
-    >
-      Username:
-    </legend>
-    <div class="cd-col-form-input mt-2 text-muted">
-      <b>{{ multisiteSetupForm.get('username').value }}</b>
-    </div>
-  </div>
-</ng-template>
-
-<ng-template #existingRealmInfo>
-  <div class="form-group row">
-    <legend
-      class="cd-col-form-label"
-      i18n
-    >
-      Selected Realm:
-    </legend>
-    <div class="cd-col-form-input mt-2 text-muted">
-      <b>{{ multisiteSetupForm.get('selectedRealm').value }}</b>
-    </div>
-  </div>
-</ng-template>
 
-<ng-template #replicationInfo>
-  <div *ngIf="isMultiClusterConfigured && !stepsToSkip['Select Cluster']">
-    <div class="form-group row">
-      <legend
-        class="cd-col-form-label"
-        i18n
-      >
-        Selected Replication Cluster:
-      </legend>
-      <div class="cd-col-form-input mt-2 text-muted">
-        <b>{{ selectedCluster }}</b>
+  <!-- newRealm path -->
+  @if (multisiteSetupForm.get('configType').value === 'newRealm') {
+    <dl class="review-list review-list-end">
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Realm name
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ multisiteSetupForm.get('realmName').value }}
+        </dd>
       </div>
-    </div>
-    <div class="form-group row">
-      <legend
-        class="cd-col-form-label"
-        i18n
-      >
-        Replication Zone Name:
-      </legend>
-      <div class="cd-col-form-input mt-2 text-muted">
-        <b>{{ multisiteSetupForm.get('replicationZoneName').value }}</b>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Zonegroup name
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ multisiteSetupForm.get('zonegroupName').value }}
+        </dd>
       </div>
-    </div>
-  </div>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Zonegroup endpoints
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ (multisiteSetupForm.get('zonegroup_endpoints').value || []).join(', ') }}
+        </dd>
+      </div>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Zone name
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ multisiteSetupForm.get('zoneName').value }}
+        </dd>
+      </div>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Zone endpoints
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ (multisiteSetupForm.get('zone_endpoints').value || []).join(', ') }}
+        </dd>
+      </div>
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Username
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ multisiteSetupForm.get('username').value }}
+        </dd>
+      </div>
+      @if (isMultiClusterConfigured && !stepsToSkip['Select cluster']) {
+        <div class="review-item">
+          <dt
+            class="cds--label review-label"
+            i18n
+          >
+            Selected replication cluster
+          </dt>
+          <dd class="cds--type-body-compact-01 review-value">{{ selectedCluster }}</dd>
+        </div>
+        <div class="review-item">
+          <dt
+            class="cds--label review-label"
+            i18n
+          >
+            Replication zone name
+          </dt>
+          <dd class="cds--type-body-compact-01 review-value">
+            {{ multisiteSetupForm.get('replicationZoneName').value }}
+          </dd>
+        </div>
+      }
+    </dl>
+  }
+
+  <!-- existingRealm path -->
+  @if (multisiteSetupForm.get('configType').value === 'existingRealm') {
+    <dl class="review-list review-list-end">
+      <div class="review-item">
+        <dt
+          class="cds--label review-label"
+          i18n
+        >
+          Selected realm
+        </dt>
+        <dd class="cds--type-body-compact-01 review-value">
+          {{ multisiteSetupForm.get('selectedRealm').value }}
+        </dd>
+      </div>
+      @if (isMultiClusterConfigured && !stepsToSkip['Select cluster']) {
+        <div class="review-item">
+          <dt
+            class="cds--label review-label"
+            i18n
+          >
+            Selected replication cluster
+          </dt>
+          <dd class="cds--type-body-compact-01 review-value">{{ selectedCluster }}</dd>
+        </div>
+        <div class="review-item">
+          <dt
+            class="cds--label review-label"
+            i18n
+          >
+            Replication zone name
+          </dt>
+          <dd class="cds--type-body-compact-01 review-value">
+            {{ multisiteSetupForm.get('replicationZoneName').value }}
+          </dd>
+        </div>
+      }
+    </dl>
+  }
 </ng-template>
index bd53b868bcfd076b6057c826f974756d09fefdc3..5c64c18e77bf91fe4ae9ac63112795845bd3418b 100644 (file)
@@ -1,3 +1,47 @@
 cds-loading {
-  margin-left: 0.5rem;
+  margin-left: var(--cds-spacing-03);
+}
+
+.review-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.review-item {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: var(--cds-spacing-05);
+  padding-bottom: var(--cds-spacing-05);
+  border-bottom: 1px solid var(--cds-border-subtle);
+}
+
+.review-list-end .review-item:last-child {
+  border-bottom: none;
+  margin-bottom: 0;
+}
+
+.review-label {
+  margin-bottom: var(--cds-spacing-02);
+  font-weight: 400;
+  color: var(--cds-text-secondary);
+  letter-spacing: var(--cds-label-01-letter-spacing, 0.32px);
+  font-size: var(--cds-label-01-font-size, 0.75rem);
+}
+
+.review-value {
+  margin: 0;
+  padding: 0;
+  font-weight: 400;
+  color: var(--cds-text-primary);
+}
+
+.skip-step {
+  margin-top: var(--cds-spacing-06);
+  padding-top: var(--cds-spacing-05);
+  border-top: 1px solid var(--cds-border-subtle);
+
+  button {
+    padding-left: 0;
+  }
 }
index 1844d136970bb5a7f2ecf5499760b27ce01032e7..7f2635b8644672758ddd999989388a2d6a007b5c 100644 (file)
@@ -5,28 +5,683 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { SharedModule } from '~/app/shared/shared.module';
 import { ReactiveFormsModule } from '@angular/forms';
-
 import { RouterTestingModule } from '@angular/router/testing';
 import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core';
+import { of, Subject } from 'rxjs';
+
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
+import { RgwRealmService } from '~/app/shared/api/rgw-realm.service';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import {
+  STEP_TITLES_EXISTING_REALM,
+  STEP_TITLES_MULTI_CLUSTER_CONFIGURED,
+  STEP_TITLES_SINGLE_CLUSTER
+} from './multisite-wizard-steps.enum';
+
+/** Minimal MultiClusterConfig response with no remote clusters. */
+const SINGLE_CLUSTER_CONFIG = {
+  current_url: 'http://localhost',
+  current_user: 'admin',
+  hub_url: '',
+  config: {
+    local: [{ url: 'http://localhost', name: 'local', cluster_alias: 'Local', user: 'admin' }]
+  }
+};
+
+/** MultiClusterConfig response with one remote cluster. */
+const MULTI_CLUSTER_CONFIG = {
+  current_url: 'http://localhost',
+  current_user: 'admin',
+  hub_url: '',
+  config: {
+    local: [{ url: 'http://localhost', name: 'local', cluster_alias: 'Local', user: 'admin' }],
+    remote: [{ url: 'http://remote', name: 'remote-fsid', cluster_alias: 'Remote', user: 'admin' }]
+  }
+};
+
+const EMPTY_REALMS = { default_info: '', realms: [] };
+const SOME_REALMS = { default_info: 'realm-a', realms: ['realm-a', 'realm-b'] };
+
+function buildDaemon(id = 'rgw.1', hostname = 'host1', port = 8080) {
+  return { id, server_hostname: hostname, port };
+}
 
 describe('RgwMultisiteWizardComponent', () => {
   let component: RgwMultisiteWizardComponent;
   let fixture: ComponentFixture<RgwMultisiteWizardComponent>;
 
+  let daemonSpy: jasmine.Spy;
+  let daemonGetSpy: jasmine.Spy;
+  let multiClusterSpy: jasmine.Spy;
+  let realmListSpy: jasmine.Spy;
+  let rgwModuleStatusSpy: jasmine.Spy;
+  let setupReplicationSpy: jasmine.Spy;
+  let summarySubscribeSpy: jasmine.Spy;
+  let mgrModuleUpdateCompleted$: Subject<void>;
+
   beforeEach(async () => {
+    mgrModuleUpdateCompleted$ = new Subject<void>();
+
+    daemonSpy = jasmine.createSpy('list').and.returnValue(of([buildDaemon()]));
+    daemonGetSpy = jasmine
+      .createSpy('get')
+      .and.returnValue(of({ rgw_metadata: { 'frontend_config#0': 'port=8080' } }));
+    multiClusterSpy = jasmine.createSpy('getCluster').and.returnValue(of(SINGLE_CLUSTER_CONFIG));
+    realmListSpy = jasmine.createSpy('list').and.returnValue(of(EMPTY_REALMS));
+    rgwModuleStatusSpy = jasmine.createSpy('getRgwModuleStatus').and.returnValue(of(true));
+    setupReplicationSpy = jasmine.createSpy('setUpMultisiteReplication').and.returnValue(of([]));
+    summarySubscribeSpy = jasmine.createSpy('subscribe').and.returnValue({ unsubscribe: () => {} });
+
     await TestBed.configureTestingModule({
       declarations: [RgwMultisiteWizardComponent],
       imports: [HttpClientTestingModule, SharedModule, ReactiveFormsModule, RouterTestingModule],
       schemas: [NO_ERRORS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA],
-      providers: [NgbActiveModal]
+      providers: [
+        NgbActiveModal,
+        {
+          provide: RgwDaemonService,
+          useValue: { list: daemonSpy, get: daemonGetSpy }
+        },
+        {
+          provide: MultiClusterService,
+          useValue: { getCluster: multiClusterSpy }
+        },
+        {
+          provide: RgwRealmService,
+          useValue: { list: realmListSpy }
+        },
+        {
+          provide: RgwMultisiteService,
+          useValue: {
+            setUpMultisiteReplication: setupReplicationSpy,
+            setRestartGatewayMessage: jasmine.createSpy('setRestartGatewayMessage'),
+            getRgwModuleStatus: rgwModuleStatusSpy
+          }
+        },
+        {
+          provide: MgrModuleService,
+          useValue: {
+            updateCompleted$: mgrModuleUpdateCompleted$,
+            updateModuleState: jasmine.createSpy('updateModuleState'),
+            list: jasmine.createSpy('list').and.returnValue(of([]))
+          }
+        },
+        {
+          provide: SummaryService,
+          useValue: { subscribe: summarySubscribeSpy }
+        },
+        {
+          provide: NotificationService,
+          useValue: { show: jasmine.createSpy('show') }
+        }
+      ]
     }).compileComponents();
 
     fixture = TestBed.createComponent(RgwMultisiteWizardComponent);
     component = fixture.componentInstance;
-    fixture.detectChanges();
+    // Stub cdr.detectChanges() before ngOnInit runs. ChangeDetectorRef is a
+    // view-level token that cannot be overridden in the root providers array;
+    // spying on the instance is the correct approach. This prevents the
+    // component's internal cdr.detectChanges() calls from triggering template
+    // rendering, which would fail on Carbon custom-element value accessors
+    // (NG01203 / DynamicInputComboboxDirective teardown crash).
+    spyOn(component['cdr'], 'detectChanges');
+    component.ngOnInit();
   });
 
+  // ─── basic ───────────────────────────────────────────────────────────────────
+
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  // ─── createForm ──────────────────────────────────────────────────────────────
+
+  describe('createForm', () => {
+    it('initialises all expected form controls', () => {
+      const controls = [
+        'realmName',
+        'zonegroupName',
+        'zonegroup_endpoints',
+        'zoneName',
+        'zone_endpoints',
+        'username',
+        'cluster',
+        'replicationZoneName',
+        'configType',
+        'selectedRealm',
+        'secondary_archive_zone'
+      ];
+      controls.forEach((name) => expect(component.multisiteSetupForm.get(name)).not.toBeNull());
+    });
+
+    it('sets sensible default values', () => {
+      const f = component.multisiteSetupForm;
+      expect(f.get('realmName').value).toBe('default_realm');
+      expect(f.get('zonegroupName').value).toBe('default_zonegroup');
+      expect(f.get('zoneName').value).toBe('default_zone');
+      expect(f.get('username').value).toBe('default_system_user');
+      expect(f.get('replicationZoneName').value).toBe('new_replicated_zone');
+      expect(f.get('configType').value).toBe('newRealm');
+      expect(f.get('secondary_archive_zone').value).toBe(false);
+    });
+  });
+
+  // ─── ngOnInit — single cluster ───────────────────────────────────────────────
+
+  describe('ngOnInit — single cluster', () => {
+    it('sets isMultiClusterConfigured to false when no remote clusters', () => {
+      expect(component.isMultiClusterConfigured).toBe(false);
+    });
+
+    it('uses STEP_TITLES_SINGLE_CLUSTER step titles', () => {
+      const labels = component.stepTitles.map((s) => s.label);
+      expect(labels).toEqual(STEP_TITLES_SINGLE_CLUSTER);
+    });
+
+    it('clears validators on cluster and replicationZoneName controls', () => {
+      expect(component.multisiteSetupForm.get('cluster').validator).toBeNull();
+      expect(component.multisiteSetupForm.get('replicationZoneName').validator).toBeNull();
+    });
+
+    it('does not show configType radio when there are no realms', () => {
+      expect(component.showConfigType).toBe(false);
+    });
+  });
+
+  // ─── ngOnInit — multi cluster ────────────────────────────────────────────────
+
+  describe('ngOnInit — multi cluster', () => {
+    beforeEach(() => {
+      multiClusterSpy.and.returnValue(of(MULTI_CLUSTER_CONFIG));
+      component.ngOnInit();
+    });
+
+    it('sets isMultiClusterConfigured to true', () => {
+      expect(component.isMultiClusterConfigured).toBe(true);
+    });
+
+    it('uses STEP_TITLES_MULTI_CLUSTER_CONFIGURED step titles', () => {
+      const labels = component.stepTitles.map((s) => s.label);
+      expect(labels).toEqual(STEP_TITLES_MULTI_CLUSTER_CONFIGURED);
+    });
+
+    it('pre-selects the first remote cluster', () => {
+      expect(component.selectedCluster).toBe('remote-fsid');
+    });
+
+    it('populates clusterDetailsArray with remote clusters only', () => {
+      expect(component.clusterDetailsArray.length).toBe(1);
+      expect(component.clusterDetailsArray[0].name).toBe('remote-fsid');
+    });
+  });
+
+  // ─── ngOnInit — realm list ────────────────────────────────────────────────────
+
+  describe('ngOnInit — realm list', () => {
+    it('leaves showConfigType false when realm list is empty', () => {
+      expect(component.showConfigType).toBe(false);
+    });
+
+    it('sets showConfigType true and pre-selects first realm when realms exist', () => {
+      realmListSpy.and.returnValue(of(SOME_REALMS));
+      multiClusterSpy.and.returnValue(of(MULTI_CLUSTER_CONFIG));
+      component.ngOnInit();
+
+      expect(component.showConfigType).toBe(true);
+      expect(component.realmList).toEqual(['realm-a', 'realm-b']);
+      expect(component.multisiteSetupForm.get('selectedRealm').value).toBe('realm-a');
+    });
+  });
+
+  // ─── selectedCluster sync via valueChanges ───────────────────────────────────
+
+  describe('selectedCluster sync', () => {
+    it('updates selectedCluster when cluster form control value changes', () => {
+      component.multisiteSetupForm.get('cluster').setValue('new-fsid');
+      expect(component.selectedCluster).toBe('new-fsid');
+    });
+  });
+
+  // ─── RGW endpoint loading ─────────────────────────────────────────────────────
+
+  describe('loadRGWEndpoints', () => {
+    it('calls RgwDaemonService.list on init', () => {
+      expect(daemonSpy).toHaveBeenCalled();
+    });
+
+    it('builds HTTP endpoint strings from daemon stats', () => {
+      expect(component.rgwEndpoints.value).toEqual(['http://host1:8080']);
+    });
+
+    it('marks endpoint as HTTPS when frontend_config contains ssl_port', () => {
+      daemonGetSpy.and.returnValue(of({ rgw_metadata: { 'frontend_config#0': 'ssl_port=8443' } }));
+      component['loadRGWEndpoints']();
+      expect(component.rgwEndpoints.value).toEqual(['https://host1:8080']);
+    });
+
+    it('sets zonegroup_endpoints and zone_endpoints form controls', () => {
+      expect(component.multisiteSetupForm.get('zonegroup_endpoints').value).toEqual([
+        'http://host1:8080'
+      ]);
+      expect(component.multisiteSetupForm.get('zone_endpoints').value).toEqual([
+        'http://host1:8080'
+      ]);
+    });
+
+    it('resets endpoint controls to null when an empty array is set', () => {
+      component.multisiteSetupForm.get('zonegroup_endpoints').setValue([]);
+      expect(component.multisiteSetupForm.get('zonegroup_endpoints').value).toBeNull();
+    });
+  });
+
+  // ─── step validity ────────────────────────────────────────────────────────────
+
+  describe('step0Valid', () => {
+    it('is true when configType is newRealm and required fields are filled', () => {
+      component.multisiteSetupForm.patchValue({
+        realmName: 'r',
+        zonegroupName: 'zg',
+        zonegroup_endpoints: ['http://host:8080']
+      });
+      expect(component.step0Valid).toBe(true);
+    });
+
+    it('is false when realmName is empty and configType is newRealm', () => {
+      component.multisiteSetupForm.get('realmName').setValue('');
+      expect(component.step0Valid).toBe(false);
+    });
+
+    it('is true when configType is existingRealm and selectedRealm is set', () => {
+      component.multisiteSetupForm.patchValue({
+        configType: 'existingRealm',
+        selectedRealm: 'realm-a'
+      });
+      expect(component.step0Valid).toBe(true);
+    });
+
+    it('is true when configType is existingRealm and selectedRealm is null (no validator on selectedRealm)', () => {
+      component.multisiteSetupForm.patchValue({
+        configType: 'existingRealm',
+        selectedRealm: null
+      });
+      // selectedRealm has no Validators.required, so the control is always
+      // valid and step0Valid returns true regardless of the value.
+      expect(component.step0Valid).toBe(true);
+    });
+  });
+
+  describe('step1Valid', () => {
+    it('is true when configType is newRealm and zone fields are valid', () => {
+      component.multisiteSetupForm.patchValue({
+        zoneName: 'zone1',
+        zone_endpoints: ['http://host:8080'],
+        username: 'user1'
+      });
+      expect(component.step1Valid).toBe(true);
+    });
+
+    it('is false when zoneName is empty', () => {
+      component.multisiteSetupForm.get('zoneName').setValue('');
+      expect(component.step1Valid).toBe(false);
+    });
+
+    it('is true when configType is existingRealm and cluster + replicationZoneName valid', () => {
+      component.multisiteSetupForm.patchValue({
+        configType: 'existingRealm',
+        cluster: 'remote-fsid',
+        replicationZoneName: 'rep-zone'
+      });
+      expect(component.step1Valid).toBe(true);
+    });
+  });
+
+  describe('step2Valid', () => {
+    it('is true when cluster and replicationZoneName have values', () => {
+      component.multisiteSetupForm.patchValue({
+        cluster: 'remote-fsid',
+        replicationZoneName: 'rep-zone'
+      });
+      expect(component.step2Valid).toBe(true);
+    });
+
+    it('is false when cluster is null and the validator is active', () => {
+      // The single-cluster init path clears cluster validators; restore them
+      // here to match the multi-cluster scenario where step2 is reachable.
+      const { Validators } = require('@angular/forms');
+      component.multisiteSetupForm.get('cluster').setValidators([Validators.required]);
+      component.multisiteSetupForm.get('cluster').setValue(null);
+      component.multisiteSetupForm.get('cluster').updateValueAndValidity();
+      expect(component.step2Valid).toBe(false);
+    });
+  });
+
+  // ─── markStep*Touched ─────────────────────────────────────────────────────────
+
+  describe('markStep0Touched', () => {
+    it('marks realmName, zonegroupName, zonegroup_endpoints for newRealm', () => {
+      component.multisiteSetupForm.get('configType').setValue('newRealm');
+      component.markStep0Touched();
+      ['realmName', 'zonegroupName', 'zonegroup_endpoints'].forEach((n) => {
+        expect(component.multisiteSetupForm.get(n).touched).toBe(true);
+        expect(component.multisiteSetupForm.get(n).dirty).toBe(true);
+      });
+    });
+
+    it('marks selectedRealm for existingRealm', () => {
+      component.multisiteSetupForm.get('configType').setValue('existingRealm');
+      component.markStep0Touched();
+      expect(component.multisiteSetupForm.get('selectedRealm').touched).toBe(true);
+    });
+  });
+
+  describe('markStep1Touched', () => {
+    it('marks zoneName, zone_endpoints, username for newRealm', () => {
+      component.multisiteSetupForm.get('configType').setValue('newRealm');
+      component.markStep1Touched();
+      ['zoneName', 'zone_endpoints', 'username'].forEach((n) => {
+        expect(component.multisiteSetupForm.get(n).touched).toBe(true);
+      });
+    });
+
+    it('marks cluster, replicationZoneName for existingRealm', () => {
+      component.multisiteSetupForm.get('configType').setValue('existingRealm');
+      component.markStep1Touched();
+      ['cluster', 'replicationZoneName'].forEach((n) => {
+        expect(component.multisiteSetupForm.get(n).touched).toBe(true);
+      });
+    });
+  });
+
+  // ─── showSubmitButtonLabel ────────────────────────────────────────────────────
+
+  describe('showSubmitButtonLabel', () => {
+    it('returns Close when setup is completed', () => {
+      component.setupCompleted = true;
+      expect(component.showSubmitButtonLabel()).toBe('Close');
+    });
+
+    it('returns Configure Multi-Site when multi-cluster configured and not skipped', () => {
+      component.setupCompleted = false;
+      component.isMultiClusterConfigured = true;
+      component.stepsToSkip['Select cluster'] = false;
+      expect(component.showSubmitButtonLabel()).toBe('Configure Multi-Site');
+    });
+
+    it('returns Export Multi-Site token when single cluster', () => {
+      component.setupCompleted = false;
+      component.isMultiClusterConfigured = false;
+      expect(component.showSubmitButtonLabel()).toBe('Export Multi-Site token');
+    });
+
+    it('returns Export Multi-Site token when Select cluster is skipped', () => {
+      component.setupCompleted = false;
+      component.isMultiClusterConfigured = true;
+      component.stepsToSkip['Select cluster'] = true;
+      expect(component.showSubmitButtonLabel()).toBe('Export Multi-Site token');
+    });
+  });
+
+  // ─── skipSelectCluster ────────────────────────────────────────────────────────
+
+  describe('skipSelectCluster', () => {
+    it('marks Select cluster as skipped', () => {
+      component.skipSelectCluster();
+      expect(component.stepsToSkip['Select cluster']).toBe(true);
+    });
+
+    it('clears validators on cluster and replicationZoneName', () => {
+      // Set validators first so we can verify they are cleared
+      component.multisiteSetupForm.get('cluster').setValidators(() => ({ required: true }));
+      component.skipSelectCluster();
+      expect(component.multisiteSetupForm.get('cluster').validator).toBeNull();
+      expect(component.multisiteSetupForm.get('replicationZoneName').validator).toBeNull();
+    });
+  });
+
+  // ─── onStepChanged ────────────────────────────────────────────────────────────
+
+  describe('onStepChanged', () => {
+    beforeEach(() => {
+      multiClusterSpy.and.returnValue(of(MULTI_CLUSTER_CONFIG));
+      component.ngOnInit();
+    });
+
+    it('restores validators when navigating back to Select cluster step after skip', () => {
+      component.skipSelectCluster();
+      const selectClusterIndex = component.stepTitles.findIndex(
+        (s) => s.label === 'Select cluster'
+      );
+      component.onStepChanged({ current: selectClusterIndex });
+
+      expect(component.stepsToSkip['Select cluster']).toBe(false);
+      expect(component.multisiteSetupForm.get('cluster').validator).not.toBeNull();
+      expect(component.multisiteSetupForm.get('replicationZoneName').validator).not.toBeNull();
+    });
+
+    it('does nothing when navigating to a step that is not Select cluster', () => {
+      component.skipSelectCluster();
+      component.onStepChanged({ current: 0 });
+      expect(component.stepsToSkip['Select cluster']).toBe(true);
+    });
+  });
+
+  // ─── onValidateStep ───────────────────────────────────────────────────────────
+
+  describe('onValidateStep', () => {
+    it('calls markStep0Touched for step 0', () => {
+      spyOn(component, 'markStep0Touched');
+      component.onValidateStep({ step: 0 });
+      expect(component.markStep0Touched).toHaveBeenCalled();
+    });
+
+    it('calls markStep1Touched for step 1', () => {
+      spyOn(component, 'markStep1Touched');
+      component.onValidateStep({ step: 1 });
+      expect(component.markStep1Touched).toHaveBeenCalled();
+    });
+
+    it('calls markStep2Touched for step 2 when it is not the last step', () => {
+      spyOn(component, 'markStep2Touched');
+      // Simulate 4-step wizard (multi-cluster configured) by setting stepTitles directly
+      component.stepTitles = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((label) => ({ label }));
+      component.onValidateStep({ step: 2 });
+      expect(component.markStep2Touched).toHaveBeenCalled();
+    });
+
+    it('does not call markStep2Touched when step 2 is the last step', () => {
+      spyOn(component, 'markStep2Touched');
+      // 3-step wizard (single cluster), step 2 IS the last step
+      component.onValidateStep({ step: 2 });
+      expect(component.markStep2Touched).not.toHaveBeenCalled();
+    });
+  });
+
+  // ─── onConfigTypeChange ───────────────────────────────────────────────────────
+
+  describe('onConfigTypeChange', () => {
+    it('switches to STEP_TITLES_EXISTING_REALM when existingRealm selected', () => {
+      component.onConfigTypeChange({ value: 'existingRealm' });
+      expect(component.stepTitles.map((s) => s.label)).toEqual(STEP_TITLES_EXISTING_REALM);
+    });
+
+    it('switches to STEP_TITLES_MULTI_CLUSTER_CONFIGURED when newRealm and multi-cluster', () => {
+      component.isMultiClusterConfigured = true;
+      component.onConfigTypeChange({ value: 'newRealm' });
+      expect(component.stepTitles.map((s) => s.label)).toEqual(
+        STEP_TITLES_MULTI_CLUSTER_CONFIGURED
+      );
+    });
+
+    it('switches to STEP_TITLES_SINGLE_CLUSTER when newRealm and single-cluster', () => {
+      component.isMultiClusterConfigured = false;
+      component.onConfigTypeChange({ value: 'newRealm' });
+      expect(component.stepTitles.map((s) => s.label)).toEqual(STEP_TITLES_SINGLE_CLUSTER);
+    });
+
+    it('reads configType from form when no event value is passed', () => {
+      component.multisiteSetupForm.get('configType').setValue('existingRealm');
+      component.onConfigTypeChange();
+      expect(component.stepTitles.map((s) => s.label)).toEqual(STEP_TITLES_EXISTING_REALM);
+    });
+  });
+
+  // ─── onSubmit — single cluster ───────────────────────────────────────────────
+
+  describe('onSubmit — single cluster (export token)', () => {
+    beforeEach(() => {
+      // Ensure form is valid
+      component.multisiteSetupForm.patchValue({
+        realmName: 'my-realm',
+        zonegroupName: 'my-zg',
+        zonegroup_endpoints: ['http://host:8080'],
+        zoneName: 'my-zone',
+        zone_endpoints: ['http://host:8080'],
+        username: 'admin'
+      });
+      component.isMultiClusterConfigured = false;
+    });
+
+    it('calls setUpMultisiteReplication with correct arguments', () => {
+      component.onSubmit();
+      expect(setupReplicationSpy).toHaveBeenCalledWith(
+        'my-realm',
+        'my-zg',
+        'http://host:8080',
+        'my-zone',
+        'http://host:8080',
+        'admin'
+      );
+    });
+
+    it('sets setupCompleted to true on success', () => {
+      component.onSubmit();
+      expect(component.setupCompleted).toBe(true);
+    });
+
+    it('sets loading to false on success', () => {
+      component.onSubmit();
+      expect(component.loading).toBe(false);
+    });
+
+    it('closes the tearsheet when setupCompleted is already true', () => {
+      component.tearsheet = { closeTearsheet: jest.fn() } as any;
+      component.setupCompleted = true;
+      component.onSubmit();
+      expect(component.tearsheet.closeTearsheet).toHaveBeenCalled();
+      expect(setupReplicationSpy).not.toHaveBeenCalled();
+    });
+
+    it('does not submit when form is invalid', () => {
+      component.multisiteSetupForm.get('realmName').setValue('');
+      component.onSubmit();
+      expect(setupReplicationSpy).not.toHaveBeenCalled();
+    });
+  });
+
+  // ─── onSubmit — multi cluster ─────────────────────────────────────────────────
+
+  describe('onSubmit — multi-cluster (configure replication)', () => {
+    const remoteCluster = {
+      url: 'http://remote',
+      name: 'remote-fsid',
+      cluster_alias: 'Remote',
+      user: 'admin',
+      token: '',
+      cluster_connection_status: 0,
+      ssl_verify: false,
+      ssl_certificate: '',
+      ttl: 0
+    };
+
+    beforeEach(() => {
+      component.isMultiClusterConfigured = true;
+      component.clusterDetailsArray = [remoteCluster];
+      component.stepsToSkip['Select cluster'] = false;
+      component.multisiteSetupForm.patchValue({
+        realmName: 'r',
+        zonegroupName: 'zg',
+        zonegroup_endpoints: ['http://host:8080'],
+        zoneName: 'z',
+        zone_endpoints: ['http://host:8080'],
+        username: 'user',
+        cluster: 'remote-fsid',
+        replicationZoneName: 'rep-zone'
+      });
+    });
+
+    it('calls setUpMultisiteReplication with cluster and replicationZoneName', () => {
+      component.onSubmit();
+      expect(setupReplicationSpy).toHaveBeenCalledWith(
+        'r',
+        'zg',
+        'http://host:8080',
+        'z',
+        'http://host:8080',
+        'user',
+        'remote-fsid',
+        'rep-zone',
+        '',
+        [remoteCluster],
+        ''
+      );
+    });
+
+    it('sets completionSummary on success', () => {
+      component.onSubmit();
+      expect(component.completionSummary).toEqual({
+        realm: 'r',
+        secondaryCluster: 'Remote - remote-fsid',
+        secondaryZone: 'rep-zone'
+      });
+    });
+
+    it('passes archive tier type when secondary_archive_zone is checked', () => {
+      component.multisiteSetupForm.get('secondary_archive_zone').setValue(true);
+      component.onSubmit();
+      expect(setupReplicationSpy.calls.mostRecent().args[8]).toBe('archive');
+    });
+
+    it('sets form cdSubmitButton error on failure', () => {
+      setupReplicationSpy.and.returnValue(new (require('rxjs').throwError)('error'));
+      component.onSubmit();
+      expect(component.multisiteSetupForm.errors).toEqual({ cdSubmitButton: true });
+    });
+  });
+
+  // ─── onSubmit — RGW module disabled ──────────────────────────────────────────
+
+  describe('onSubmit — RGW module disabled', () => {
+    let mgrService: MgrModuleService;
+
+    beforeEach(() => {
+      mgrService = TestBed.inject(MgrModuleService);
+      component.rgwModuleStatus = false;
+      component.isMultiClusterConfigured = false;
+      component.multisiteSetupForm.patchValue({
+        realmName: 'r',
+        zonegroupName: 'zg',
+        zonegroup_endpoints: ['http://host:8080'],
+        zoneName: 'z',
+        zone_endpoints: ['http://host:8080'],
+        username: 'user'
+      });
+    });
+
+    it('calls updateModuleState before proceeding', () => {
+      component.onSubmit();
+      expect(mgrService.updateModuleState).toHaveBeenCalled();
+    });
+
+    it('calls setUpMultisiteReplication after updateCompleted$ emits', () => {
+      component.onSubmit();
+      expect(setupReplicationSpy).not.toHaveBeenCalled();
+      mgrModuleUpdateCompleted$.next();
+      expect(setupReplicationSpy).toHaveBeenCalled();
+    });
+  });
 });
index fd6ec99edd78caed1e901b86ebdf4fea4e49142d..cf24448a0810253611b84cdc8709a0cba5be2d99 100644 (file)
@@ -1,25 +1,19 @@
-import { ChangeDetectorRef, Component, NgZone, OnInit } from '@angular/core';
+import { ChangeDetectorRef, Component, NgZone, OnInit, ViewChild } from '@angular/core';
 import { Location } from '@angular/common';
 import { UntypedFormControl, Validators } from '@angular/forms';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-import { Observable, Subscription, forkJoin } from 'rxjs';
-import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Observable, forkJoin } from 'rxjs';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-import { WizardStepModel } from '~/app/shared/models/wizard-steps';
-import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
 import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
 import { RgwDaemon } from '../models/rgw-daemon';
 import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
 import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
-import { Icons } from '~/app/shared/enum/icons.enum';
-import { SelectOption } from '~/app/shared/components/select/select-option.model';
-import _ from 'lodash';
-import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { ComboBoxItem } from '~/app/shared/models/combo-box.model';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { NotificationService } from '~/app/shared/services/notification.service';
-import { ActivatedRoute } from '@angular/router';
 import { map, switchMap } from 'rxjs/operators';
-import { BaseModal, Step } from 'carbon-components-angular';
+import { Step } from 'carbon-components-angular';
+import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
 import { SummaryService } from '~/app/shared/services/summary.service';
 import { ExecutingTask } from '~/app/shared/models/executing-task';
 import {
@@ -64,11 +58,11 @@ enum ConfigType {
   styleUrls: ['./rgw-multisite-wizard.component.scss'],
   standalone: false
 })
-export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
-  multisiteSetupForm: CdFormGroup;
-  currentStep: WizardStepModel;
-  currentStepSub: Subscription;
-  permissions: Permissions;
+export class RgwMultisiteWizardComponent implements OnInit {
+  @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
+
+  multisiteSetupForm!: CdFormGroup;
+  permissions!: Permissions;
   stepTitles: Step[] = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((title) => ({
     label: title
   }));
@@ -77,79 +71,80 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
   selectedCluster = '';
   clusterDetailsArray: MultiCluster[] = [];
   isMultiClusterConfigured = false;
-  exportTokenForm: CdFormGroup;
-  realms: any;
+  exportTokenForm!: CdFormGroup;
+  realms: Array<{ realm: string; token: string }> = [];
   loading = false;
-  pageURL: string;
-  icons = Icons;
-  rgwEndpoints: { value: any[]; options: any[]; messages: any };
-  executingTask: ExecutingTask;
+  zonegroupEndpointsOptions: ComboBoxItem[] = [];
+  zoneEndpointsOptions: ComboBoxItem[] = [];
+  rgwEndpoints: { value: any[] };
+  executingTask: ExecutingTask | undefined;
   setupCompleted = false;
   showConfigType = false;
   realmList: string[] = [];
-  rgwModuleStatus: boolean;
+  rgwModuleStatus!: boolean;
+  title = $localize`Set up Multi-site Replication`;
+  description = $localize`Configure realm, zonegroup, and zone for multi-site replication across clusters.`;
+  completionSummary: {
+    realm: string;
+    secondaryCluster: string;
+    secondaryZone: string;
+  } | null = null;
 
   constructor(
-    private wizardStepsService: WizardStepsService,
     public activeModal: NgbActiveModal,
-    public actionLabels: ActionLabelsI18n,
     private rgwDaemonService: RgwDaemonService,
     private multiClusterService: MultiClusterService,
     private rgwMultisiteService: RgwMultisiteService,
     private rgwRealmService: RgwRealmService,
     public notificationService: NotificationService,
-    private route: ActivatedRoute,
     private summaryService: SummaryService,
     private location: Location,
     private cdr: ChangeDetectorRef,
     private mgrModuleService: MgrModuleService,
     private zone: NgZone
   ) {
-    super();
-    this.pageURL = 'rgw/multisite/configuration';
-    this.currentStepSub = this.wizardStepsService
-      .getCurrentStep()
-      .subscribe((step: WizardStepModel) => {
-        this.currentStep = step;
-      });
-    this.currentStep.stepIndex = 0;
     this.createForm();
-    this.rgwEndpoints = {
-      value: [],
-      options: [],
-      messages: new SelectMessages({
-        empty: $localize`There are no endpoints.`,
-        filter: $localize`Select endpoints`
-      })
-    };
+    this.rgwEndpoints = { value: [] };
   }
 
   ngOnInit(): void {
-    this.open = this.route.outlet === 'modal';
     this.loadRGWEndpoints();
-    this.multiClusterService.getCluster().subscribe((clusters: MultiClusterConfig) => {
-      const currentUrl = clusters['current_url'];
-      this.clusterDetailsArray = Object.values(clusters['config'])
-        .flat()
-        .filter((cluster) => cluster['url'] !== currentUrl);
-      this.isMultiClusterConfigured = this.clusterDetailsArray.length > 0;
-      this.stepTitles = (
-        this.isMultiClusterConfigured
-          ? STEP_TITLES_MULTI_CLUSTER_CONFIGURED
-          : STEP_TITLES_SINGLE_CLUSTER
-      ).map((label, index) => ({
-        label,
-        onClick: () => (this.currentStep.stepIndex = index)
-      }));
-      this.wizardStepsService.setTotalSteps(this.stepTitles.length);
-      this.selectedCluster = this.isMultiClusterConfigured
-        ? this.clusterDetailsArray[0]['name']
-        : null;
+    (this.multiClusterService.getCluster() as Observable<MultiClusterConfig>).subscribe(
+      (clusters: MultiClusterConfig) => {
+        const currentUrl = clusters['current_url'];
+        this.clusterDetailsArray = Object.values(clusters['config'])
+          .flat()
+          .filter((cluster) => cluster['url'] !== currentUrl);
+        this.isMultiClusterConfigured = this.clusterDetailsArray.length > 0;
+        this.stepTitles = (
+          this.isMultiClusterConfigured
+            ? STEP_TITLES_MULTI_CLUSTER_CONFIGURED
+            : STEP_TITLES_SINGLE_CLUSTER
+        ).map((label) => ({ label }));
+        this.selectedCluster = this.isMultiClusterConfigured
+          ? this.clusterDetailsArray[0]['name']
+          : '';
+
+        if (this.isMultiClusterConfigured) {
+          this.multisiteSetupForm.get('cluster').setValue(this.clusterDetailsArray[0]['name']);
+        }
+
+        if (!this.isMultiClusterConfigured) {
+          this.multisiteSetupForm.get('cluster').clearValidators();
+          this.multisiteSetupForm.get('cluster').updateValueAndValidity();
+          this.multisiteSetupForm.get('replicationZoneName').clearValidators();
+          this.multisiteSetupForm.get('replicationZoneName').updateValueAndValidity();
+        }
+      }
+    );
+
+    this.multisiteSetupForm.get('cluster').valueChanges.subscribe((v: string) => {
+      this.selectedCluster = v;
     });
 
     this.summaryService.subscribe((summary) => {
       this.zone.run(() => {
-        this.executingTask = summary.executing_tasks.find((task) =>
+        this.executingTask = summary?.executing_tasks?.find((task) =>
           task.name.includes('progress/Multisite-Setup')
         );
         this.cdr.detectChanges();
@@ -160,7 +155,7 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
       this.stepsToSkip[step.label] = false;
     });
 
-    this.rgwRealmService.list().subscribe((realmsInfo: RealmsInfo) => {
+    (this.rgwRealmService.list() as Observable<RealmsInfo>).subscribe((realmsInfo: RealmsInfo) => {
       this.realmList = realmsInfo?.realms || [];
       this.showConfigType = this.realmList.length > 0;
       if (this.showConfigType) {
@@ -206,9 +201,25 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
       const protocol = stats.frontendConfig.includes('ssl_port') ? Protocol.HTTPS : Protocol.HTTP;
       return `${protocol}://${stats.hostname}:${stats.port}`;
     });
-    this.rgwEndpoints.options = this.rgwEndpoints.value.map(
-      (endpoint) => new SelectOption(false, endpoint, '')
-    );
+    const makeOptions = () =>
+      this.rgwEndpoints.value.map((endpoint) => ({
+        content: endpoint,
+        name: endpoint,
+        selected: true
+      }));
+    this.zonegroupEndpointsOptions = makeOptions();
+    this.zoneEndpointsOptions = makeOptions();
+    const selectedEndpoints = this.rgwEndpoints.value;
+    this.multisiteSetupForm.get('zonegroup_endpoints').setValue(selectedEndpoints);
+    this.multisiteSetupForm.get('zone_endpoints').setValue(selectedEndpoints);
+    ['zonegroup_endpoints', 'zone_endpoints'].forEach((controlName) => {
+      this.multisiteSetupForm.get(controlName).valueChanges.subscribe((val: string[]) => {
+        if (Array.isArray(val) && val.length === 0) {
+          this.multisiteSetupForm.get(controlName).setValue(null, { emitEvent: false });
+        }
+      });
+    });
+
     this.cdr.detectChanges();
   }
 
@@ -246,47 +257,116 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
     }
   }
 
-  showSubmitButtonLabel() {
-    if (this.wizardStepsService.isLastStep()) {
-      if (!this.setupCompleted) {
-        if (this.isMultiClusterConfigured) {
-          return $localize`Configure Multi-Site`;
-        } else {
-          return $localize`Export Multi-Site token`;
-        }
-      } else {
-        return $localize`Close`;
-      }
-    } else {
-      return $localize`Next`;
+  get step0Valid(): boolean {
+    const f = this.multisiteSetupForm;
+    if (f.get('configType').value === 'existingRealm') {
+      return f.get('selectedRealm').valid;
     }
+    return (
+      f.get('realmName').valid && f.get('zonegroupName').valid && f.get('zonegroup_endpoints').valid
+    );
   }
 
-  showCancelButtonLabel() {
-    return !this.wizardStepsService.isFirstStep()
-      ? this.actionLabels.BACK
-      : this.actionLabels.CANCEL;
+  get step1Valid(): boolean {
+    const f = this.multisiteSetupForm;
+    if (f.get('configType').value === 'existingRealm') {
+      return f.get('cluster').valid && f.get('replicationZoneName').valid;
+    }
+    return f.get('zoneName').valid && f.get('zone_endpoints').valid && f.get('username').valid;
   }
 
-  onNextStep() {
-    if (!this.wizardStepsService.isLastStep()) {
-      this.wizardStepsService.moveToNextStep();
+  get step2Valid(): boolean {
+    const f = this.multisiteSetupForm;
+    return f.get('cluster').valid && f.get('replicationZoneName').valid;
+  }
+
+  markStep0Touched(): void {
+    const f = this.multisiteSetupForm;
+    if (f.get('configType').value === 'existingRealm') {
+      ['selectedRealm'].forEach((n) => {
+        f.get(n).markAsTouched();
+        f.get(n).markAsDirty();
+      });
     } else {
-      if (this.setupCompleted) {
-        this.closeModal();
-      } else {
-        this.onSubmit();
-      }
+      ['realmName', 'zonegroupName', 'zonegroup_endpoints'].forEach((n) => {
+        f.get(n).markAsTouched();
+        f.get(n).markAsDirty();
+      });
     }
-    this.wizardStepsService.getCurrentStep().subscribe((step: WizardStepModel) => {
-      this.currentStep = step;
-      if (this.currentStep.stepIndex === 2 && this.isMultiClusterConfigured) {
-        this.stepsToSkip['Select Cluster'] = false;
-      }
+  }
+
+  markStep1Touched(): void {
+    const f = this.multisiteSetupForm;
+    if (f.get('configType').value === 'existingRealm') {
+      ['cluster', 'replicationZoneName'].forEach((n) => {
+        f.get(n).markAsTouched();
+        f.get(n).markAsDirty();
+      });
+    } else {
+      ['zoneName', 'zone_endpoints', 'username'].forEach((n) => {
+        f.get(n).markAsTouched();
+        f.get(n).markAsDirty();
+      });
+    }
+  }
+
+  markStep2Touched(): void {
+    const f = this.multisiteSetupForm;
+    ['cluster', 'replicationZoneName'].forEach((n) => {
+      f.get(n).markAsTouched();
+      f.get(n).markAsDirty();
     });
   }
 
+  showSubmitButtonLabel() {
+    if (this.setupCompleted) {
+      return $localize`Close`;
+    }
+    if (this.isMultiClusterConfigured && !this.stepsToSkip['Select cluster']) {
+      return $localize`Configure Multi-Site`;
+    }
+    return $localize`Export Multi-Site token`;
+  }
+
+  skipSelectCluster(): void {
+    this.stepsToSkip['Select cluster'] = true;
+    this.multisiteSetupForm.get('cluster').clearValidators();
+    this.multisiteSetupForm.get('cluster').updateValueAndValidity();
+    this.multisiteSetupForm.get('replicationZoneName').clearValidators();
+    this.multisiteSetupForm.get('replicationZoneName').updateValueAndValidity();
+    this.tearsheet?.onNext();
+  }
+
+  onValidateStep(event: { step: number }): void {
+    const lastStep = this.stepTitles.length - 1;
+    if (event.step === 0) this.markStep0Touched();
+    else if (event.step === 1) this.markStep1Touched();
+    else if (event.step === 2 && event.step !== lastStep) this.markStep2Touched();
+  }
+
+  onStepChanged(event: { current: number }): void {
+    const selectClusterIndex = this.stepTitles.findIndex((s) => s.label === 'Select cluster');
+    if (event.current === selectClusterIndex && this.stepsToSkip['Select cluster']) {
+      this.stepsToSkip['Select cluster'] = false;
+      this.multisiteSetupForm.get('cluster').setValidators([Validators.required]);
+      this.multisiteSetupForm.get('cluster').updateValueAndValidity();
+      this.multisiteSetupForm.get('replicationZoneName').setValidators([Validators.required]);
+      this.multisiteSetupForm.get('replicationZoneName').updateValueAndValidity();
+    }
+  }
+
   onSubmit() {
+    if (this.setupCompleted) {
+      this.tearsheet.closeTearsheet();
+      return;
+    }
+
+    this.multisiteSetupForm.markAllAsTouched();
+    Object.values(this.multisiteSetupForm.controls).forEach((c) => c.markAsDirty());
+    if (this.multisiteSetupForm.invalid) {
+      return;
+    }
+
     this.loading = true;
 
     const proceedWithSetup = () => {
@@ -294,11 +374,15 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
       const values = this.multisiteSetupForm.getRawValue();
       const realmName = values['realmName'];
       const zonegroupName = values['zonegroupName'];
-      const zonegroupEndpoints = this.rgwEndpoints.value.join(',');
+      const zonegroupEndpoints = (values['zonegroup_endpoints'] as string[] | string | null)
+        ? [].concat(values['zonegroup_endpoints']).join(',')
+        : '';
       const zoneName = values['zoneName'];
-      const zoneEndpoints = this.rgwEndpoints.value.join(',');
+      const zoneEndpoints = (values['zone_endpoints'] as string[] | string | null)
+        ? [].concat(values['zone_endpoints']).join(',')
+        : '';
       const username = values['username'];
-      if (!this.isMultiClusterConfigured || this.stepsToSkip['Select Cluster']) {
+      if (!this.isMultiClusterConfigured || this.stepsToSkip['Select cluster']) {
         this.rgwMultisiteService
           .setUpMultisiteReplication(
             realmName,
@@ -312,7 +396,11 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
             this.setupCompleted = true;
             this.rgwMultisiteService.setRestartGatewayMessage(false);
             this.loading = false;
-            this.realms = data;
+            this.realms = (Array.isArray(data) ? data : []).filter(
+              (r: object) => r['realm'] === realmName
+            );
+            this.title = $localize`Multi-site replication created`;
+            this.description = $localize`The replication configuration has been created successfully.`;
             this.showSuccessNotification();
           });
       } else {
@@ -344,6 +432,21 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
               this.setupCompleted = true;
               this.rgwMultisiteService.setRestartGatewayMessage(false);
               this.loading = false;
+              const selectedClusterDetail = this.clusterDetailsArray.find(
+                (c) => c['name'] === values['cluster']
+              );
+              this.completionSummary = {
+                realm:
+                  this.multisiteSetupForm.get('configType').value === 'existingRealm'
+                    ? values['selectedRealm']
+                    : realmName,
+                secondaryCluster: selectedClusterDetail
+                  ? `${selectedClusterDetail['cluster_alias']} - ${selectedClusterDetail['name']}`
+                  : values['cluster'],
+                secondaryZone: values['replicationZoneName']
+              };
+              this.title = $localize`Multi-site replication created`;
+              this.description = $localize`The replication configuration has been created successfully.`;
               this.showSuccessNotification();
             },
             () => {
@@ -357,7 +460,7 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
       this.mgrModuleService.updateModuleState(
         'rgw',
         false,
-        null,
+        undefined,
         '',
         '',
         false,
@@ -379,42 +482,18 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit {
     );
   }
 
-  onPreviousStep() {
-    if (!this.wizardStepsService.isFirstStep()) {
-      this.wizardStepsService.moveToPreviousStep();
-    } else {
-      this.location.back();
-    }
-  }
-
-  onSkip() {
-    const stepTitle = this.stepTitles[this.currentStep.stepIndex];
-    this.stepsToSkip[stepTitle.label] = true;
-    this.onNextStep();
-  }
-
   closeModal(): void {
     this.location.back();
   }
 
-  onConfigTypeChange() {
-    const configType = this.multisiteSetupForm.get('configType')?.value;
+  onConfigTypeChange(event?: { value: string }) {
+    const configType = event?.value ?? this.multisiteSetupForm.get('configType')?.value;
     if (configType === ConfigType.ExistingRealm) {
-      this.stepTitles = STEP_TITLES_EXISTING_REALM.map((title) => ({
-        label: title
-      }));
-      this.stepTitles.forEach((steps, index) => {
-        steps.onClick = () => (this.currentStep.stepIndex = index);
-      });
+      this.stepTitles = STEP_TITLES_EXISTING_REALM.map((title) => ({ label: title }));
     } else if (this.isMultiClusterConfigured) {
-      this.stepTitles = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((title) => ({
-        label: title
-      }));
+      this.stepTitles = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((title) => ({ label: title }));
     } else {
-      this.stepTitles = STEP_TITLES_SINGLE_CLUSTER.map((title) => ({
-        label: title
-      }));
+      this.stepTitles = STEP_TITLES_SINGLE_CLUSTER.map((title) => ({ label: title }));
     }
-    this.wizardStepsService.setTotalSteps(this.stepTitles.length);
   }
 }
index dfdd0e68fc3ed2175af838674fa1875ff5d6c66d..7b74bb573806b52e9af414fdf5498a522594a9dc 100644 (file)
@@ -70,6 +70,7 @@ import {
   LoadingModule,
   ModalModule,
   ProgressIndicatorModule,
+  ProgressBarModule,
   CodeSnippetModule,
   InputModule,
   CheckboxModule,
@@ -150,6 +151,7 @@ import { RgwAccountRoleFormComponent } from './rgw-account-role-form/rgw-account
     ModalModule,
     GridModule,
     ProgressIndicatorModule,
+    ProgressBarModule,
     CodeSnippetModule,
     ButtonModule,
     LoadingModule,
index bfe36c73f97cec974858112b0ab0fe2d3168e7df..2279757ed8f7b7a51f0e8768b4374ab41cfc59f4 100644 (file)
@@ -110,6 +110,6 @@ export class ErrorComponent implements OnDestroy, OnInit {
   }
 
   enableModule(): void {
-    this.mgrModuleService.updateModuleState(this.module_name, false, null, this.navigateTo);
+    this.mgrModuleService.updateModuleState(this.module_name, false, undefined, this.navigateTo);
   }
 }
index 53a508b01f3b77746ef836382143c4d97ef756f2..cc847bb7e7df918e46542c47a4cadd901f1dba54 100644 (file)
@@ -93,7 +93,7 @@ export class MgrModuleService {
   updateModuleState(
     module: string,
     enabled: boolean = false,
-    table: TableComponent = null,
+    table: TableComponent | undefined = undefined,
     navigateTo: string = '',
     notificationText?: string,
     navigateByUrl?: boolean,
index 2ddcc4abcd1a1d2def1f129efe475ae4e6f58d92..e7b9fe0ffdc6a26e5066332cef6985976256e932 100644 (file)
@@ -1,4 +1,13 @@
-import { Component, ContentChild, TemplateRef, ViewChild } from '@angular/core';
+import {
+  Component,
+  ContentChild,
+  Input,
+  OnChanges,
+  SimpleChanges,
+  TemplateRef,
+  ViewChild
+} from '@angular/core';
+import { Subject } from 'rxjs';
 import { TearsheetStep } from '../../models/tearsheet-step';
 
 @Component({
@@ -7,13 +16,33 @@ import { TearsheetStep } from '../../models/tearsheet-step';
   templateUrl: './tearsheet-step.component.html',
   styleUrls: ['./tearsheet-step.component.scss']
 })
-export class TearsheetStepComponent {
+export class TearsheetStepComponent implements OnChanges {
   @ViewChild(TemplateRef, { static: true })
   template!: TemplateRef<any>;
 
   @ContentChild('tearsheetStep')
   stepComponent!: TearsheetStep;
 
+  @Input() stepValid: boolean | null = null;
+
+  readonly validityChange$ = new Subject<boolean>();
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if ('stepValid' in changes) {
+      this.validityChange$.next(this.canProceed);
+    }
+  }
+
+  get resolvedFormGroup() {
+    return this.stepComponent?.formGroup ?? null;
+  }
+
+  get canProceed(): boolean {
+    if (this.stepValid !== null) return this.stepValid;
+    if (this.resolvedFormGroup) return this.resolvedFormGroup.valid;
+    return true;
+  }
+
   get rightInfluencer(): TemplateRef<any> | null {
     return this.stepComponent?.rightInfluencer ?? null;
   }
index 11e86406a674d6cf8cc076552881a12633308588..2fcfc1a6369d98879d96d694dea273c544f61d3b 100644 (file)
     >
       <!-- Tearsheet Header -->
       <header class="tearsheet-header tearsheet-header--full border-subtle-block-end">
-        <h4 class="cds--type-heading-04 tearsheet-header-title">
+        <h4
+          class="cds--type-heading-04 tearsheet-header-title"
+          [attr.data-testid]="headerTestId"
+        >
+          @if (successIcon) {
+            <cd-icon
+              type="success"
+              class="tearsheet-success-icon"
+            ></cd-icon>
+          }
           {{ title }}
         </h4>
         <p class="cds--type-body-02 tearsheet-header-description">
         [fullWidth]="true"
       >
         <!-- Tearsheet Influencer-->
-        <div
-          cdsCol
-          [columnNumbers]="{ lg: 3, md: 3, sm: 3 }"
-          class="tearsheet-left-influencer border-subtle-inline-end"
-        >
-          <cds-progress-indicator
-            orientation="vertical"
-            [steps]="steps"
-            [current]="currentStep"
-            spacing="equal"
-            (stepSelected)="onStepSelect($event)"
+        @if (!hideInfluencer) {
+          <div
+            cdsCol
+            [columnNumbers]="{ lg: 3, md: 3, sm: 3 }"
+            class="tearsheet-left-influencer border-subtle-inline-end"
           >
-          </cds-progress-indicator>
-        </div>
+            <cds-progress-indicator
+              orientation="vertical"
+              [steps]="steps"
+              [current]="currentStep"
+              spacing="equal"
+              (stepSelected)="onStepSelect($event)"
+            >
+            </cds-progress-indicator>
+          </div>
+        }
         <div
           cdsCol
-          [columnNumbers]="{ lg: 13, md: 13, sm: 13 }"
+          [columnNumbers]="
+            !hideInfluencer ? { lg: 13, md: 13, sm: 13 } : { lg: 16, md: 16, sm: 16 }
+          "
           class="tearsheet-main"
         >
           <!-- Tearsheet Content Area -->
               class="tearsheet-footer-cancel"
               (click)="closeTearsheet()"
               size="xl"
-              i18n
             >
-              Cancel
+              @if (isSubmitLoading) {
+                <ng-container i18n>Close</ng-container>
+              } @else {
+                <ng-container i18n>Cancel</ng-container>
+              }
             </button>
             @if (steps.length > 1) {
               <button
                 type="button"
                 cdsButton="secondary"
                 size="xl"
-                [disabled]="currentStep === 0"
+                [disabled]="currentStep === 0 || hideInfluencer"
                 (click)="onPrevious()"
                 i18n
               >
       <h4
         cdsModalHeaderHeading
         class="cds--type-heading-04 tearsheet-header-title"
+        [attr.data-testid]="headerTestId"
       >
+        @if (successIcon) {
+          <cd-icon
+            type="success"
+            class="tearsheet-success-icon"
+          ></cd-icon>
+        }
         {{ title }}
       </h4>
       <p class="cds--type-body-02 tearsheet-header-description">
       [fullWidth]="true"
     >
       <!-- Tearsheet Left Influencer-->
-      @if (steps.length > 1) {
+      @if (steps.length > 1 && !hideInfluencer) {
         <div
           cdsCol
           [columnNumbers]="{ lg: 3, md: 3, sm: 3 }"
       }
       <div
         cdsCol
-        [columnNumbers]="steps.length > 1 ? { lg: 13, md: 13, sm: 13 } : { lg: 16, md: 16, sm: 16 }"
+        [columnNumbers]="
+          steps.length > 1 && !hideInfluencer
+            ? { lg: 13, md: 13, sm: 13 }
+            : { lg: 16, md: 16, sm: 16 }
+        "
         class="tearsheet-main"
       >
         @if (showRightInfluencer) {
             cdsButton="ghost"
             (click)="closeTearsheet()"
             size="xl"
-            i18n
           >
-            Cancel
+            @if (isSubmitLoading) {
+              <ng-container i18n>Close</ng-container>
+            } @else {
+              <ng-container i18n>Cancel</ng-container>
+            }
           </button>
           @if (steps.length > 1) {
             <button
               type="button"
               cdsButton="secondary"
               size="xl"
-              [disabled]="currentStep === 0"
+              [disabled]="currentStep === 0 || hideInfluencer"
               (click)="onPrevious()"
               i18n
             >
index f888e0bdf774fdf4024ab383f9a315007e39d7b7..4a23c00c83b4f22b3a92333f2e7fe43d2d50bc1f 100644 (file)
@@ -91,6 +91,9 @@
   padding: var(--cds-spacing-06) var(--cds-spacing-07);
 
   &-title {
+    display: flex;
+    align-items: center;
+    gap: var(--cds-spacing-03);
     color: var(--cds-text-primary);
   }
 
 cds-loading {
   margin-right: var(--cds-spacing-05);
 }
+
+.tearsheet-success-icon svg {
+  width: 2rem;
+  height: 2rem;
+  fill: var(--cds-support-success);
+  flex-shrink: 0;
+}
index 791575ec8f782d557f877d72ace097285c5b9858..ffe9576c6bb307d23b201406b47f55519181ab47 100644 (file)
@@ -16,7 +16,7 @@ import { ActivatedRoute } from '@angular/router';
       [overflowScroll]="overflowScroll"
       (submitRequested)="onSubmit()"
     >
-      <cd-tearsheet-step>
+      <cd-tearsheet-step [stepValid]="step1Valid">
         <div class="step-1-content">Step 1 Content</div>
       </cd-tearsheet-step>
       <cd-tearsheet-step>
@@ -31,23 +31,15 @@ import { ActivatedRoute } from '@angular/router';
 })
 class MockHostComponent {
   steps = [
-    {
-      label: 'Step 1',
-      complete: false,
-      invalid: false
-    },
-    {
-      label: 'Step 2',
-      complete: false
-    },
-    {
-      label: 'Step 3',
-      complete: false
-    }
+    { label: 'Step 1', complete: false },
+    { label: 'Step 2', complete: false },
+    { label: 'Step 3', complete: false }
   ];
   title = 'Test Title';
   description = 'Test Description';
   overflowScroll?: TearsheetOverflowScroll;
+  /** null = no validation (default); false = force invalid; true = force valid */
+  step1Valid: boolean | null = null;
 
   onSubmit() {}
 
@@ -168,7 +160,8 @@ describe('TearsheetComponent', () => {
 
     it('should not go to next step on invalid', () => {
       tearsheetComponent.currentStep = 0;
-      hostComponent.steps[0].invalid = true;
+      hostComponent.step1Valid = false;
+      hostFixture.detectChanges();
       tearsheetComponent.onNext();
       expect(tearsheetComponent.currentStep).toBe(0);
     });
@@ -186,4 +179,57 @@ describe('TearsheetComponent', () => {
       expect(nextBtn?.nativeElement.disabled).toBe(true);
     });
   });
+
+  describe('[stepValid] seeding and live sync', () => {
+    it('should seed step as invalid immediately when stepValid starts false', () => {
+      hostComponent.step1Valid = false;
+      hostFixture.detectChanges();
+      // After detectChanges the tearsheet re-runs setup() which seeds the flag.
+      expect(tearsheetComponent.steps[0].invalid).toBe(true);
+    });
+
+    it('should seed step as valid when stepValid starts true', () => {
+      hostComponent.step1Valid = true;
+      hostFixture.detectChanges();
+      expect(tearsheetComponent.steps[0].invalid).toBe(false);
+    });
+
+    it('should clear invalid flag when stepValid changes from false to true', () => {
+      hostComponent.step1Valid = false;
+      hostFixture.detectChanges();
+      expect(tearsheetComponent.steps[0].invalid).toBe(true);
+
+      hostComponent.step1Valid = true;
+      hostFixture.detectChanges();
+      expect(tearsheetComponent.steps[0].invalid).toBe(false);
+    });
+
+    it('should set invalid flag when stepValid changes from true to false', () => {
+      hostComponent.step1Valid = true;
+      hostFixture.detectChanges();
+      expect(tearsheetComponent.steps[0].invalid).toBe(false);
+
+      hostComponent.step1Valid = false;
+      hostFixture.detectChanges();
+      expect(tearsheetComponent.steps[0].invalid).toBe(true);
+    });
+
+    it('should not seed invalid for steps with stepValid null (default)', () => {
+      // Steps 2 and 3 have stepValid=null, so they must never be seeded invalid.
+      expect(tearsheetComponent.steps[1].invalid).toBeFalsy();
+      expect(tearsheetComponent.steps[2].invalid).toBeFalsy();
+    });
+
+    it('should keep Next enabled for steps that never use [stepValid]', () => {
+      // steps[1] has no [stepValid] binding and no #tearsheetStep form —
+      // Next must stay enabled regardless.
+      tearsheetComponent.currentStep = 1;
+      hostFixture.detectChanges();
+      const buttons = hostFixture.debugElement.queryAll(
+        By.css('.tearsheet-footer button[cdsButton="primary"]')
+      );
+      const nextBtn = buttons.find((btn) => btn.nativeElement.textContent.trim() === 'Next');
+      expect(nextBtn?.nativeElement.disabled).toBe(false);
+    });
+  });
 });
index 2874b164e747967a6cf67e2baaa065cf6eb98bbf..9dc6d1b1c887ba9c3a57e47c18b89bc6acb4cdc7 100644 (file)
@@ -25,6 +25,7 @@ import { Location } from '@angular/common';
 import { ConfirmationModalComponent } from '../confirmation-modal/confirmation-modal.component';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
 
 export type TearsheetOverflowScroll = 'auto' | 'hidden' | 'visible' | 'scroll';
 
@@ -74,10 +75,14 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
   @Input() isSubmitLoading: boolean = false;
   /** When set, applies `overflow` on the tearsheet content area; omit to use stylesheet defaults. */
   @Input() overflowScroll?: TearsheetOverflowScroll;
+  @Input() hideInfluencer: boolean = false;
+  @Input() successIcon: boolean = false;
+  @Input() headerTestId?: string;
 
   @Output() submitRequested = new EventEmitter<void>();
   @Output() closeRequested = new EventEmitter<void>();
   @Output() stepChanged = new EventEmitter<{ current: number }>();
+  @Output() validateStep = new EventEmitter<{ step: number }>();
 
   @ContentChildren(TearsheetStepComponent)
   stepContents!: QueryList<TearsheetStepComponent>;
@@ -121,6 +126,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
   isOpen: boolean = true;
   hasModalOutlet: boolean = false;
   private destroy$ = new Subject<void>();
+  private setupTeardown$ = new Subject<void>();
 
   constructor(
     protected formBuilder: FormBuilder,
@@ -204,17 +210,24 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
   }
 
   onNext() {
-    const currentForm = this.stepContents?.toArray()?.[this.currentStep]?.stepComponent?.formGroup;
-    currentForm?.markAllAsTouched();
-    currentForm?.updateValueAndValidity({ emitEvent: true });
-    if (currentForm) {
-      this._updateStepInvalid(this.currentStep, currentForm.invalid);
+    this.validateStep.emit({ step: this.currentStep });
+
+    const wrapper = this.stepContents?.toArray()?.[this.currentStep];
+    const legacyForm = wrapper?.resolvedFormGroup;
+    if (legacyForm) {
+      legacyForm.markAllAsTouched();
+      legacyForm.updateValueAndValidity({ emitEvent: true });
+      this._updateStepInvalid(this.currentStep, legacyForm.invalid);
     }
 
-    if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) {
+    const canAdvance = wrapper ? wrapper.canProceed : true;
+    this._updateStepInvalid(this.currentStep, !canAdvance);
+    if (this.currentStep !== this.lastStep && canAdvance) {
       this.currentStep = this.currentStep + 1;
       this.stepChanged.emit({ current: this.currentStep });
       this.cdr.markForCheck();
+    } else if (!canAdvance) {
+      this.cdr.markForCheck();
     }
   }
 
@@ -227,18 +240,20 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
 
   onSubmit() {
     this.stepContents?.forEach((wrapper, index) => {
-      const form = wrapper.stepComponent?.formGroup;
+      const form = wrapper.resolvedFormGroup;
       if (!form) return;
-
       form.markAllAsTouched();
       form.updateValueAndValidity({ emitEvent: true });
       this._updateStepInvalid(index, form.invalid);
     });
 
-    if (this.steps.some((step) => step?.invalid)) return;
+    const wrappers = this.stepContents?.toArray() ?? [];
+    const anyStepInvalid = this.steps.some(
+      (step, index) => step?.invalid || (wrappers[index] ? !wrappers[index].canProceed : false)
+    );
+    if (anyStepInvalid) return;
 
     const mergedPayloads = this.getMergedPayload();
-
     this.submitRequested.emit(mergedPayloads);
   }
 
@@ -260,23 +275,43 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
 
   ngAfterViewInit() {
     const setup = () => {
-      // keep lastStep in sync with steps input
+      // Cancel all subscriptions created by the previous setup run before
+      // re-subscribing, so that removed steps do not retain observers.
+      this.setupTeardown$.next();
+
       this.lastStep = this.steps.length - 1;
 
-      // clamp currentStep so template lookup never goes out of range
       if (this.currentStep > this.lastStep) {
         this.currentStep = this.lastStep;
       }
 
-      // subscribe to each form statusChanges
       this.stepContents.forEach((wrapper, index) => {
-        const form = wrapper.stepComponent?.formGroup;
-        if (!form) return;
-
-        form.statusChanges
-          .pipe(takeUntilDestroyed(this.destroyRef))
-          .subscribe(() => this._updateStepInvalid(index, form.invalid));
+        // Path 1: step uses a formGroup via #tearsheetStep — subscribe to its
+        // statusChanges so the flag stays in sync as the user types.
+        // Initial state is NOT seeded here: these forms intentionally start
+        // with Next enabled so the user can navigate freely before touching fields.
+        const form = wrapper.resolvedFormGroup;
+        if (form) {
+          form.statusChanges
+            .pipe(takeUntil(this.setupTeardown$))
+            .subscribe(() => this._updateStepInvalid(index, form.invalid));
+        }
+
+        // Path 2: step uses [stepValid] input binding (no formGroup reference).
+        // Always subscribe to validityChange$ so any future [stepValid] binding
+        // is tracked. When stepValid is already set at setup time, also seed the
+        // initial invalid state so Next is correctly disabled from first render.
+        if (wrapper.stepValid !== null) {
+          this._updateStepInvalid(index, !wrapper.canProceed);
+        }
+        wrapper.validityChange$.pipe(takeUntil(this.setupTeardown$)).subscribe((canProceed) => {
+          this._updateStepInvalid(index, !canProceed);
+          this.cdr.markForCheck();
+        });
       });
+
+      // After seeding stepValid-based steps, force OnPush to re-render.
+      this.cdr.markForCheck();
     };
 
     setup();
@@ -285,6 +320,8 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
   }
 
   ngOnDestroy() {
+    this.setupTeardown$.next();
+    this.setupTeardown$.complete();
     this.destroy$.next();
     this.destroy$.complete();
   }
index 89070e30e3ae0ddda015ade6ab40eebfd2f012ab..65bb434d63e7a69fff3e0f054651e731c9d40176 100644 (file)
@@ -12,7 +12,7 @@ import { ComboBoxItem } from '../models/combo-box.model';
   standalone: false
 })
 class MockComponent {
-  items: ComboBoxItem[] = [{ content: 'Item1', name: 'Item1' }];
+  items: ComboBoxItem[] = [{ content: 'Item1', name: 'Item1', selected: false }];
   searchSubject = new Subject<string>();
   selectedItems: ComboBoxItem[] = [];
   updatedItems = new EventEmitter<ComboBoxItem[]>();
index 86589efd08e14d9ea968b3218387fe4521758d8d..84b76f6c76bc7bfe424b801955686c1f0b81db58 100644 (file)
@@ -53,7 +53,11 @@ export class DynamicInputComboboxDirective implements OnInit, OnDestroy {
         const exists = this.items.some((item: ComboBoxItem) => item.content === searchString);
 
         if (!exists) {
-          this.items = this.items.concat({ content: searchString, name: searchString });
+          this.items = this.items.concat({
+            content: searchString,
+            name: searchString,
+            selected: false
+          });
         }
         this.updatedItems.emit(this.items);
         this.combBoxService.emit({ searchString });
index 9b87f5cd19f27a23b929ba824b57e105842a1dc5..1bead57f8cb5044cfa416079c7ca3e416fbb397b 100644 (file)
@@ -1,5 +1,5 @@
 export type ComboBoxItem = {
   content: string;
   name: string;
-  selected?: boolean;
+  selected: boolean;
 };
index 90cf13ff0c2dc208274ca20ab26ad42c4453e9f2..081bc30239c82b21b3cef13dc90ad7c4d1dd5e96 100644 (file)
@@ -36,20 +36,25 @@ export interface FilterRules {
 }
 
 export const events: ComboBoxItem[] = [
-  { content: 's3:ObjectCreated:*', name: 's3:ObjectCreated:*' },
-  { content: 's3:ObjectCreated:Put', name: 's3:ObjectCreated:Put' },
-  { content: 's3:ObjectCreated:Copy', name: 's3:ObjectCreated:Copy' },
+  { content: 's3:ObjectCreated:*', name: 's3:ObjectCreated:*', selected: false },
+  { content: 's3:ObjectCreated:Put', name: 's3:ObjectCreated:Put', selected: false },
+  { content: 's3:ObjectCreated:Copy', name: 's3:ObjectCreated:Copy', selected: false },
   {
     content: 's3:ObjectCreated:CompleteMultipartUpload',
-    name: 's3:ObjectCreated:CompleteMultipartUpload'
+    name: 's3:ObjectCreated:CompleteMultipartUpload',
+    selected: false
   },
-  { content: 's3:ObjectRemoved:*', name: 's3:ObjectRemoved:*' },
-  { content: 's3:ObjectRestore:*', name: 's3:ObjectRestore:*' },
-  { content: 's3:ObjectRestore:Post', name: 's3:ObjectRestore:Post' },
-  { content: 's3:ObjectRestore:Completed', name: 's3:ObjectRestore:Completed' },
-  { content: 's3:ObjectRestore:Delete', name: 's3:ObjectRestore:Delete' },
-  { content: 's3:ObjectRemoved:Delete', name: 's3:ObjectRemoved:Delete' },
-  { content: 's3:ObjectRemoved:DeleteMarkerCreated', name: 's3:ObjectRemoved:DeleteMarkerCreated' }
+  { content: 's3:ObjectRemoved:*', name: 's3:ObjectRemoved:*', selected: false },
+  { content: 's3:ObjectRestore:*', name: 's3:ObjectRestore:*', selected: false },
+  { content: 's3:ObjectRestore:Post', name: 's3:ObjectRestore:Post', selected: false },
+  { content: 's3:ObjectRestore:Completed', name: 's3:ObjectRestore:Completed', selected: false },
+  { content: 's3:ObjectRestore:Delete', name: 's3:ObjectRestore:Delete', selected: false },
+  { content: 's3:ObjectRemoved:Delete', name: 's3:ObjectRemoved:Delete', selected: false },
+  {
+    content: 's3:ObjectRemoved:DeleteMarkerCreated',
+    name: 's3:ObjectRemoved:DeleteMarkerCreated',
+    selected: false
+  }
 ];
 
 export enum s3KeyFilter {
index 35786d29db314821155abab45a96bdbd7ebf783d..cff25d35fd8d061c09e017dc394e2f6bedaecbf9 100644 (file)
@@ -211,7 +211,7 @@ input:-webkit-autofill:active {
 }
 
 .cds--progress-bar__bar {
-  background-color: var(--cds-primary);
+  background-color: var(--cds-interactive);
 }
 // Carbon borders
 .border-subtle {