};
enum WizardSteps {
- CreateRealmZonegroup = 'Create Realm & Zonegroup',
- CreateZone = 'Create Zone',
+ CreateRealmZonegroup = 'Create realm & zonegroup',
+ CreateZone = 'Create zone',
Review = 'Review'
}
@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();
}
}
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 };
});
});
}
}
enableRgwModule(): void {
- this.mgrModuleService.updateModuleState('rgw', false, null, '', $localize`Enabled RGW Module`);
+ this.mgrModuleService.updateModuleState(
+ 'rgw',
+ false,
+ undefined,
+ '',
+ $localize`Enabled RGW Module`
+ );
}
prePopulateId() {
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 });
}
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) => {
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'
}
-<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>
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;
+ }
}
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();
+ });
+ });
});
-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 {
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
}));
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();
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) {
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();
}
}
}
- 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 = () => {
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,
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 {
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();
},
() => {
this.mgrModuleService.updateModuleState(
'rgw',
false,
- null,
+ undefined,
'',
'',
false,
);
}
- 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);
}
}
LoadingModule,
ModalModule,
ProgressIndicatorModule,
+ ProgressBarModule,
CodeSnippetModule,
InputModule,
CheckboxModule,
ModalModule,
GridModule,
ProgressIndicatorModule,
+ ProgressBarModule,
CodeSnippetModule,
ButtonModule,
LoadingModule,
}
enableModule(): void {
- this.mgrModuleService.updateModuleState(this.module_name, false, null, this.navigateTo);
+ this.mgrModuleService.updateModuleState(this.module_name, false, undefined, this.navigateTo);
}
}
updateModuleState(
module: string,
enabled: boolean = false,
- table: TableComponent = null,
+ table: TableComponent | undefined = undefined,
navigateTo: string = '',
notificationText?: string,
navigateByUrl?: boolean,
-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({
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;
}
>
<!-- 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
>
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;
+}
[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>
})
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() {}
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);
});
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);
+ });
+ });
});
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';
@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>;
isOpen: boolean = true;
hasModalOutlet: boolean = false;
private destroy$ = new Subject<void>();
+ private setupTeardown$ = new Subject<void>();
constructor(
protected formBuilder: FormBuilder,
}
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();
}
}
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);
}
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();
}
ngOnDestroy() {
+ this.setupTeardown$.next();
+ this.setupTeardown$.complete();
this.destroy$.next();
this.destroy$.complete();
}
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[]>();
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 });
export type ComboBoxItem = {
content: string;
name: string;
- selected?: boolean;
+ selected: boolean;
};
}
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 {
}
.cds--progress-bar__bar {
- background-color: var(--cds-primary);
+ background-color: var(--cds-interactive);
}
// Carbon borders
.border-subtle {