import { SmbOverviewComponent } from './ceph/smb/smb-overview/smb-overview.component';
import { MultiClusterFormComponent } from './ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component';
import { CephfsMirroringListComponent } from './ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component';
+import { CephfsAddMirroringPathComponent } from './ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component';
import { CephfsMirroringFsTabsComponent } from './ceph/cephfs/cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-tabs.component';
import { CephfsMirroringFsOverviewComponent } from './ceph/cephfs/cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component';
import { CephfsMirroringFsMirrorPathsComponent } from './ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component';
data: {
breadcrumbs: 'File/Mirroring',
pageHeader: CEPHFS_MIRRORING_PAGE_HEADER
- }
+ },
+ children: [
+ {
+ path: 'add-path/:fsId/:fsName',
+ component: CephfsAddMirroringPathComponent,
+ outlet: 'modal'
+ }
+ ]
},
{
path: ':fsName',
--- /dev/null
+<ng-template #descTpl>Filesystem: <strong>{{fsName}}</strong></ng-template>
+
+<cd-tearsheet
+ [steps]="steps"
+ [title]="title"
+ [modalHeaderLabel]="modalHeaderLabel"
+ [descriptionTemplate]="descTpl"
+ [isSubmitLoading]="isSubmitLoading"
+ progressPosition="top"
+ submitButtonLabel="Add mirror path"
+ i18n-submitButtonLabel
+ (submitRequested)="onSubmit()"
+ (closeRequested)="onCancel()">
+ <cd-tearsheet-step></cd-tearsheet-step>
+ <cd-tearsheet-step></cd-tearsheet-step>
+ <cd-tearsheet-step></cd-tearsheet-step>
+</cd-tearsheet>
--- /dev/null
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+
+import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path.component';
+
+describe('CephfsAddMirroringPathComponent', () => {
+ let component: CephfsAddMirroringPathComponent;
+ let fixture: ComponentFixture<CephfsAddMirroringPathComponent>;
+ let routerNavigateSpy: jest.Mock;
+
+ beforeEach(async () => {
+ routerNavigateSpy = jest.fn();
+
+ await TestBed.configureTestingModule({
+ declarations: [CephfsAddMirroringPathComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ paramMap: convertToParamMap({ fsId: '1', fsName: 'testfs' })
+ }
+ }
+ },
+ {
+ provide: Router,
+ useValue: { navigate: routerNavigateSpy }
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .overrideComponent(CephfsAddMirroringPathComponent, {
+ set: { template: '' }
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(CephfsAddMirroringPathComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should read route params on ngOnInit', () => {
+ component.ngOnInit();
+
+ expect(component.fsName).toBe('testfs');
+ expect(component.fsId).toBe(1);
+ });
+
+ it('should define tearsheet metadata', () => {
+ expect(component.modalHeaderLabel).toBeDefined();
+ expect(component.title).toBeDefined();
+ expect(component.steps.length).toBe(3);
+ expect(component.steps.map((step) => step.label)).toEqual(['Paths', 'Schedule', 'Review']);
+ });
+
+ it('should close modal outlet on submit', () => {
+ component.ngOnInit();
+ component.onSubmit();
+
+ expect(routerNavigateSpy).toHaveBeenCalledWith(
+ ['/cephfs/mirroring', { outlets: { modal: null } }],
+ { state: { reload: true } }
+ );
+ });
+
+ it('should close modal outlet on cancel without reload', () => {
+ component.ngOnInit();
+ component.onCancel();
+
+ expect(routerNavigateSpy).toHaveBeenCalledWith(
+ ['/cephfs/mirroring', { outlets: { modal: null } }],
+ { state: undefined }
+ );
+ });
+
+ it('should decode encoded filesystem name from route params', () => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ declarations: [CephfsAddMirroringPathComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ paramMap: convertToParamMap({ fsId: '2', fsName: encodeURIComponent('my fs') })
+ }
+ }
+ },
+ {
+ provide: Router,
+ useValue: { navigate: routerNavigateSpy }
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .overrideComponent(CephfsAddMirroringPathComponent, {
+ set: { template: '' }
+ })
+ .compileComponents();
+
+ const decodedFixture = TestBed.createComponent(CephfsAddMirroringPathComponent);
+ decodedFixture.componentInstance.ngOnInit();
+
+ expect(decodedFixture.componentInstance.fsName).toBe('my fs');
+ expect(decodedFixture.componentInstance.fsId).toBe(2);
+ });
+});
--- /dev/null
+import { Component, inject, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Step } from 'carbon-components-angular';
+
+import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant';
+
+@Component({
+ selector: 'cd-cephfs-add-mirroring-path',
+ templateUrl: './cephfs-add-mirroring-path.component.html',
+ styleUrls: ['./cephfs-add-mirroring-path.component.scss'],
+ standalone: false
+})
+export class CephfsAddMirroringPathComponent implements OnInit {
+ fsName = '';
+ fsId = 0;
+ modalHeaderLabel = $localize`Filesystem mirroring`;
+ title = $localize`Add mirroring path`;
+ steps: Step[] = [
+ { label: $localize`Paths`, invalid: false },
+ { label: $localize`Schedule`, invalid: false },
+ { label: $localize`Review`, invalid: false }
+ ];
+ isSubmitLoading = false;
+
+ private route = inject(ActivatedRoute);
+ private router = inject(Router);
+
+ ngOnInit(): void {
+ this.fsId = Number(this.route.snapshot.paramMap.get('fsId'));
+ const fsName = this.route.snapshot.paramMap.get('fsName') ?? '';
+ try {
+ this.fsName = decodeURIComponent(fsName);
+ } catch {
+ this.fsName = fsName;
+ }
+ }
+
+ onSubmit(): void {
+ this.closeTearsheet(true);
+ }
+
+ onCancel(): void {
+ this.closeTearsheet(false);
+ }
+
+ private closeTearsheet(reload: boolean): void {
+ this.router.navigate([CEPHFS_MIRRORING_URL, { outlets: { modal: null } }], {
+ state: reload ? { reload: true } : undefined
+ });
+ }
+}
--- /dev/null
+export interface MirroringPathSelection {
+ path: string;
+ subvol?: string;
+ group?: string;
+}
<div cdsStack="vertical"
gap="5"
class="cds-mt-6">
- <h4 class="cds--type-heading-03"
- i18n>Jump in</h4>
+ <span class="cds--type-heading-03"
+ i18n>Jump in</span>
<div cdsGrid
- [narrow]="true">
- <div cdsRow
- class="jump-in-row">
+ class="padding-inline-0 jump-in-grid"
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+ @for (tile of jumpInTiles; track tile.title) {
<div cdsCol
[columnNumbers]="{lg: 4, md: 4, sm: 4}">
- <cds-clickable-tile (click)="openSetupMirroring()">
- <div cdsStack="vertical"
- gap="5">
- <p class="cds--type-heading-compact-01"
- i18n>Set up mirroring</p>
- <p class="cds--type-body-compact-01"
- i18n>
- Configure mirroring for a filesystem by importing a token from a peer cluster and adding paths to replicate.
- </p>
- <div [cdsStack]="'horizontal'">
- <cd-icon type="replicate"
- size="24"
- class="cds-ml-1 cds-mr-12"></cd-icon>
- <span class="cds--text-right">→</span>
- </div>
- </div>
- </cds-clickable-tile>
+ <cd-clickable-tile
+ [title]="tile.title"
+ [description]="tile.description"
+ [icon]="tile.icon"
+ (tileClick)="tile.action()">
+ </cd-clickable-tile>
</div>
- <div cdsCol
- [columnNumbers]="{lg: 4, md: 4, sm: 4}">
- <cds-clickable-tile (click)="openPrepareToReceive(); $event.preventDefault()">
- <div cdsStack="vertical"
- gap="5">
- <p class="cds--type-heading-compact-01"
- i18n>Prepare to receive</p>
- <p class="cds--type-body-compact-01"
- i18n>
- Generate a bootstrap token for a filesystem to allow a peer cluster to replicate data to it.
- </p>
- <div [cdsStack]="'horizontal'">
- <cd-icon type="share"
- size="24"
- class="cds-ml-3 cds-mr-12"></cd-icon>
- <span class="cds--text-right">→</span>
- </div>
- </div>
- </cds-clickable-tile>
- </div>
- </div>
+ }
</div>
</div>
<!-- Mirrored filesystems table -->
<section class="cds-mt-6">
- <h3 class="cds--type-heading-03 cds-mb-3"
- i18n>Mirrored filesystems</h3>
+ <p class="cds--type-heading-03 cds-mb-3"
+ i18n>Mirrored filesystems</p>
<cd-table
+ #table
[data]="daemonStatus$ | async"
[columns]="columns"
columnMode="flex"
selectionType="single"
+ emptyStateTitle="No mirrored filesystems"
+ i18n-emptyStateTitle
+ emptyStateMessage="Set up mirroring to protect critical workloads by replicating data to a secondary cluster."
+ i18n-emptyStateMessage
+ (updateSelection)="updateSelection($event)"
(fetchData)="loadDaemonStatus()">
+ <div class="table-actions">
+ <cd-table-actions
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
</cd-table>
</section>
@if (isPrepareModalOpen) {
<cd-cephfs-generate-token
[open]="true"
- (tokenGenerated)="onTokenGenerated($event)"
+ (tokenGenerated)="onTokenGenerated()"
(cancelled)="closePrepareModal()">
</cd-cephfs-generate-token>
}
(mirroringSetup)="closeSetupModal()">
</cd-cephfs-setup-mirroring>
}
+
+<router-outlet name="modal"></router-outlet>
-.cds--grid.cds--grid--narrow {
- margin-left: 20px;
- padding-left: 0;
-}
-
-.jump-in-row {
- align-items: stretch;
-
+.jump-in-grid {
+ cd-clickable-tile,
cds-clickable-tile,
.cds--tile {
height: 100%;
+import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
import { of } from 'rxjs';
import { CephfsMirroringListComponent } from './cephfs-mirroring-list.component';
import { CephfsService } from '~/app/shared/api/cephfs.service';
import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
describe('CephfsMirroringListComponent', () => {
let component: CephfsMirroringListComponent;
let fixture: ComponentFixture<CephfsMirroringListComponent>;
+ let routerNavigateSpy: jest.Mock;
const cephfsServiceMock = {
listDaemonStatus: jest.fn()
};
+ const authStorageServiceMock = {
+ getPermissions: jest.fn().mockReturnValue({ cephfs: {} as Permission })
+ };
+
beforeEach(async () => {
jest.clearAllMocks();
+ routerNavigateSpy = jest.fn();
await TestBed.configureTestingModule({
declarations: [CephfsMirroringListComponent],
- providers: [{ provide: CephfsService, useValue: cephfsServiceMock }]
+ providers: [
+ { provide: CephfsService, useValue: cephfsServiceMock },
+ { provide: AuthStorageService, useValue: authStorageServiceMock },
+ {
+ provide: Router,
+ useValue: {
+ navigate: routerNavigateSpy,
+ url: '/cephfs/mirroring',
+ events: of()
+ }
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
fixture = TestBed.createComponent(CephfsMirroringListComponent);
cephfsServiceMock.listDaemonStatus.mockReturnValue(of([]));
component.ngOnInit();
+ expect(component.jumpInTiles.length).toBe(2);
expect(component.columns.length).toBe(6);
expect(component.columns[0].prop).toBe('local_fs_name');
});
fs_name: 'fsA',
client_name: 'clientA',
directory_count: 3,
+ filesystem_id: 10,
id: '1-10'
});
});
fs_name: 'fs2',
client_name: '-',
directory_count: 5,
+ filesystem_id: 20,
peerId: '-',
id: '2-20'
});
});
+
+ it('should not navigate to add path modal when filesystem_id is missing', () => {
+ component.selection = new CdTableSelection([{ local_fs_name: 'fs1' } as MirroringRow]);
+
+ component.openAddPath();
+
+ expect(routerNavigateSpy).not.toHaveBeenCalled();
+ });
+
+ it('should navigate to add path modal outlet when a filesystem is selected', () => {
+ component.selection = new CdTableSelection([
+ { local_fs_name: 'fs1', filesystem_id: 10 } as MirroringRow
+ ]);
+
+ component.openAddPath();
+
+ expect(routerNavigateSpy).toHaveBeenCalledWith([
+ '/cephfs/mirroring',
+ {
+ outlets: {
+ modal: ['add-path', 10, encodeURIComponent('fs1')]
+ }
+ }
+ ]);
+ });
});
-import { Component, OnInit, ViewEncapsulation } from '@angular/core';
+import { Component, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
+import { NavigationEnd, Router } from '@angular/router';
import { Subject, of } from 'rxjs';
-import { catchError, map, switchMap } from 'rxjs/operators';
+import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators';
import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/cephfs.model';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { MirroringJumpInTile } from './cephfs-mirroring-list.model';
@Component({
selector: 'cd-cephfs-mirroring-list',
standalone: false,
encapsulation: ViewEncapsulation.None
})
-export class CephfsMirroringListComponent implements OnInit {
+export class CephfsMirroringListComponent implements OnInit, OnDestroy {
+ @ViewChild('table', { static: true }) table: TableComponent;
+
+ private cephfsService = inject(CephfsService);
+ private authStorageService = inject(AuthStorageService);
+ private router = inject(Router);
+
columns: CdTableColumn[];
+ tableActions: CdTableAction[];
isSetupModalOpen = false;
selection = new CdTableSelection();
+ permission = this.authStorageService.getPermissions().cephfs;
+ isPrepareModalOpen = false;
+ jumpInTiles: MirroringJumpInTile[] = [];
private subject$ = new Subject<void>();
+ private destroy$ = new Subject<void>();
+ private previousUrl = '';
daemonStatus$ = this.subject$.pipe(
switchMap(() =>
map((daemons) => this.buildRows(daemons))
);
- isPrepareModalOpen = false;
-
- constructor(private cephfsService: CephfsService) {}
-
- ngOnInit() {
+ ngOnInit(): void {
+ this.jumpInTiles = this.buildJumpInTiles();
this.columns = [
{
name: $localize`Filesystem`,
flexGrow: 2,
cellTransformation: CellTemplate.redirect,
customTemplateConfig: {
- redirectLink: ['/cephfs/mirroring', '::prop', 'overview']
+ redirectLink: [CEPHFS_MIRRORING_URL, '::prop', 'overview']
}
},
{ name: $localize`Destination cluster`, prop: 'remote_cluster_name', flexGrow: 2 },
{ name: $localize`Last sync`, prop: 'last_sync', flexGrow: 2 },
{ name: $localize`Replicated paths`, prop: 'directory_count', flexGrow: 2 }
];
+ this.tableActions = [
+ {
+ name: $localize`Add mirror path`,
+ permission: 'update',
+ icon: Icons.add,
+ click: () => this.openAddPath(),
+ disable: (selection: CdTableSelection) => !selection.hasSingleSelection
+ }
+ ];
+ this.previousUrl = this.router.url;
+ this.router.events
+ .pipe(
+ filter((event): event is NavigationEnd => event instanceof NavigationEnd),
+ takeUntil(this.destroy$)
+ )
+ .subscribe((event) => {
+ const hadModal = this.previousUrl.includes('(modal:');
+ const hasModal = event.urlAfterRedirects.includes('(modal:');
+ if (hadModal && !hasModal) {
+ this.loadDaemonStatus();
+ }
+ this.previousUrl = event.urlAfterRedirects;
+ });
this.loadDaemonStatus();
}
- loadDaemonStatus() {
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ updateSelection(selection: CdTableSelection): void {
+ this.selection = selection;
+ }
+
+ loadDaemonStatus(): void {
this.subject$.next();
}
- openPrepareToReceive() {
+ openPrepareToReceive(): void {
this.isPrepareModalOpen = true;
}
- closePrepareModal() {
+ closePrepareModal(): void {
this.isPrepareModalOpen = false;
this.loadDaemonStatus();
}
- onTokenGenerated(_response: any) {
+ onTokenGenerated(): void {
this.loadDaemonStatus();
}
- openSetupMirroring() {
+ openSetupMirroring(): void {
this.isSetupModalOpen = true;
}
- closeSetupModal() {
+ closeSetupModal(): void {
this.isSetupModalOpen = false;
this.loadDaemonStatus();
}
+ openAddPath(): void {
+ const selected = this.selection.first();
+ if (!selected?.filesystem_id || !selected?.local_fs_name) {
+ return;
+ }
+
+ this.router.navigate([
+ CEPHFS_MIRRORING_URL,
+ {
+ outlets: {
+ modal: ['add-path', selected.filesystem_id, encodeURIComponent(selected.local_fs_name)]
+ }
+ }
+ ]);
+ }
+
+ private buildJumpInTiles(): MirroringJumpInTile[] {
+ return [
+ {
+ title: $localize`Set up mirroring`,
+ description: $localize`Configure mirroring for a filesystem by importing a token from a peer cluster and adding paths to replicate.`,
+ icon: 'replicate',
+ action: () => this.openSetupMirroring()
+ },
+ {
+ title: $localize`Prepare to receive`,
+ description: $localize`Generate a bootstrap token for a filesystem to allow a peer cluster to replicate data to it.`,
+ icon: 'share',
+ action: () => this.openPrepareToReceive()
+ }
+ ];
+ }
+
private buildRows(daemons: Daemon[]): MirroringRow[] {
const rows: MirroringRow[] = [];
if (!daemons?.length) {
}
for (const daemon of daemons) {
- if (!daemon?.filesystems) continue;
+ if (!daemon?.filesystems) {
+ continue;
+ }
for (const fs of daemon.filesystems) {
if (fs.peers?.length) {
for (const peer of fs.peers) {
fs_name: peer.remote?.fs_name ?? '-',
client_name: peer.remote?.client_name ?? '-',
directory_count: fs.directory_count ?? 0,
+ filesystem_id: fs.filesystem_id,
id: `${daemon.daemon_id}-${fs.filesystem_id}`
};
}
fs_name: fs.name,
client_name: '-',
directory_count: fs.directory_count ?? 0,
+ filesystem_id: fs.filesystem_id,
peerId: '-',
id: `${daemon.daemon_id}-${fs.filesystem_id}`
};
--- /dev/null
+export interface MirroringJumpInTile {
+ title: string;
+ description: string;
+ icon: string;
+ action: () => void;
+}
import { CephfsAuthModalComponent } from './cephfs-auth-modal/cephfs-auth-modal.component';
import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component';
import { CephfsMirroringErrorComponent } from './cephfs-mirroring-error/cephfs-mirroring-error.component';
+import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path/cephfs-add-mirroring-path.component';
import { CephfsMirroringFsTabsComponent } from './cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-tabs.component';
import { CephfsMirroringFsOverviewComponent } from './cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component';
import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component';
CephfsMirroringFsSchedulesComponent,
CephfsGenerateTokenComponent,
CephfsDownloadTokenComponent,
- CephfsSetupMirroringComponent
+ CephfsSetupMirroringComponent,
+ CephfsAddMirroringPathComponent
],
providers: [provideCharts(withDefaultRegisterables())]
})
--- /dev/null
+<cds-clickable-tile (click)="tileClick.emit()">
+ <div cdsStack="vertical"
+ gap="5">
+ <div>
+ <p class="cds--type-heading-compact-01">{{ title }}</p>
+ <p class="cds--type-body-compact-01">{{ description }}</p>
+ </div>
+ <cd-icon [type]="icon"
+ size="24"></cd-icon>
+ </div>
+</cds-clickable-tile>
--- /dev/null
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ClickableTileComponent } from './clickable-tile.component';
+
+describe('ClickableTileComponent', () => {
+ let component: ClickableTileComponent;
+ let fixture: ComponentFixture<ClickableTileComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [ClickableTileComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ClickableTileComponent);
+ component = fixture.componentInstance;
+ component.title = 'Set up mirroring';
+ component.description = 'Configure mirroring for a filesystem.';
+ component.icon = 'replicate';
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render title and description', () => {
+ const element: HTMLElement = fixture.nativeElement;
+
+ expect(element.textContent).toContain('Set up mirroring');
+ expect(element.textContent).toContain('Configure mirroring for a filesystem.');
+ });
+
+ it('should emit tileClick when the tile is clicked', () => {
+ const tileClickSpy = jest.fn();
+ component.tileClick.subscribe(tileClickSpy);
+
+ fixture.nativeElement.querySelector('cds-clickable-tile')?.dispatchEvent(new Event('click'));
+
+ expect(tileClickSpy).toHaveBeenCalledTimes(1);
+ });
+});
--- /dev/null
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+@Component({
+ selector: 'cd-clickable-tile',
+ templateUrl: './clickable-tile.component.html',
+ standalone: false
+})
+export class ClickableTileComponent {
+ @Input({ required: true }) title!: string;
+ @Input({ required: true }) description!: string;
+ @Input({ required: true }) icon!: string;
+
+ @Output() tileClick = new EventEmitter<void>();
+}
import { DetailsCardComponent } from './details-card/details-card.component';
import { ToastComponent } from './notification-toast/notification-toast.component';
import { TearsheetComponent } from './tearsheet/tearsheet.component';
+import { ClickableTileComponent } from './clickable-tile/clickable-tile.component';
// Icons
import InfoIcon from '@carbon/icons/es/information/16';
ToastComponent,
TearsheetComponent,
TearsheetStepComponent,
+ ClickableTileComponent,
PageHeaderComponent,
SidebarLayoutComponent,
NumberWithUnitComponent
ToastComponent,
TearsheetComponent,
TearsheetStepComponent,
+ ClickableTileComponent,
PageHeaderComponent,
SidebarLayoutComponent,
NumberWithUnitComponent
size="xl"
[disabled]="currentStep === 0"
(click)="onPrevious()"
- i18n>Previous</button>
+ i18n>{{previousButtonLabel}}</button>
}
@if (currentStep === lastStep) {
<button cdsButton="primary"
<!-- Tearsheet Header -->
<header
class="tearsheet-header border-subtle-block-end">
+ @if (modalHeaderLabel) {
+ <p class="cds--type-label-01 tearsheet-header-label">
+ {{modalHeaderLabel}}
+ </p>
+ }
<h4 cdsModalHeaderHeading
class="cds--type-heading-04 tearsheet-header-title">
{{title}}
</h4>
+ @if (descriptionTemplate) {
+ <p class="cds--type-body-02 tearsheet-header-description">
+ <ng-container *ngTemplateOutlet="descriptionTemplate"></ng-container>
+ </p>
+ } @else {
<p class="cds--type-body-02 tearsheet-header-description">
{{description}}
</p>
+ }
</header>
+ <!-- Top progress indicator -->
+ @if (steps.length > 1 && progressPosition === 'top') {
+ <div class="tearsheet-top-progress">
+ <cds-progress-indicator
+ [steps]="steps"
+ [current]="currentStep"
+ spacing="equal"
+ (stepSelected)="onStepSelect($event)">
+ </cds-progress-indicator>
+ </div>
+ }
<section cdsGrid
class="tearsheet-body"
[condensed]="true"
[useCssGrid]="true"
[fullWidth]="true">
<!-- Tearsheet Left Influencer-->
- @if (steps.length > 1) {
+ @if (steps.length > 1 && progressPosition === 'left') {
<div cdsCol
[columnNumbers]="{lg: 3, md: 3, sm: 3}"
class="tearsheet-left-influencer border-subtle-inline-end">
</div>
}
<div cdsCol
- [columnNumbers]="steps.length > 1 ? {lg: 13, md: 13, sm: 13} : {lg: 16, md: 16, sm: 16}"
+ [columnNumbers]="(steps.length > 1 && progressPosition === 'left') ? {lg: 13, md: 13, sm: 13} : {lg: 16, md: 16, sm: 16}"
class="tearsheet-main">
@if (showRightInfluencer) {
<!-- Tearsheet content with right influencer -->
size="xl"
[disabled]="currentStep === 0"
(click)="onPrevious()"
- i18n>Previous</button>
+ i18n>{{previousButtonLabel}}</button>
}
@if (currentStep === lastStep) {
<button cdsButton="primary"
background-color: var(--cds-background);
padding: var(--cds-spacing-06) var(--cds-spacing-07);
+ &-label {
+ color: var(--cds-text-secondary);
+ margin-bottom: var(--cds-spacing-02);
+ }
+
&-title {
color: var(--cds-text-primary);
}
}
}
+.tearsheet-top-progress {
+ padding: var(--cds-spacing-05) var(--cds-spacing-07);
+ flex-shrink: 0;
+}
+
// BODY
.tearsheet-body {
margin: 0;
import { ConfirmationModalComponent } from '../confirmation-modal/confirmation-modal.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject } from 'rxjs';
+import { ActionLabelsI18n } from '../../constants/app.constants';
export type TearsheetOverflowScroll = 'auto' | 'hidden' | 'visible' | 'scroll';
[title]="title"
[isSubmitLoading]="isSubmitLoading"
[description]="description"
+ modalHeaderLabel="Top label header"
+ progressPosition="top"
+ previousButtonLabel="back"
(submitRequested)="onSubmit()">
<cd-tearsheet-step>
<cd-step #tearsheetStep>
@Component({
selector: 'cd-step',
- template: `<form></form>,
+ template: `<form></form>`,
standalone: false
})
export class StepComponent implements TearsheetStep {
-formgroup: CdFormGroup;
+ formGroup: CdFormGroup;
}
**/
@Component({
})
export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() title!: string;
+ @Input() modalHeaderLabel: string;
@Input() steps!: Array<Step>;
@Input() description!: string;
+ @Input() descriptionTemplate: TemplateRef<any>;
@Input() type: 'full' | 'wide' = 'wide';
@Input() size: 'xs' | 'sm' | 'md' | 'lg' = 'lg';
- @Input() submitButtonLabel: string = $localize`Create`;
- @Input() submitButtonLoadingLabel: string = $localize`Creating`;
+ @Input() progressPosition: 'left' | 'top' = 'left';
+ @Input() submitButtonLabel: string;
+ @Input() submitButtonLoadingLabel: string;
+ @Input() previousButtonLabel: string;
@Input() isSubmitLoading: boolean = false;
/** When set, applies `overflow` on the tearsheet content area; omit to use stylesheet defaults. */
@Input() overflowScroll?: TearsheetOverflowScroll;
private route: ActivatedRoute,
private location: Location,
private destroyRef: DestroyRef,
- private cdr: ChangeDetectorRef
+ private cdr: ChangeDetectorRef,
+ private actionLabels: ActionLabelsI18n
) {}
ngOnInit() {
+ this.submitButtonLabel ??= this.actionLabels.CREATE;
+ this.submitButtonLoadingLabel ??= this.actionLabels.CREATING;
+ this.previousButtonLabel ??= this.actionLabels.PREVIOUS;
this.lastStep = this.steps.length - 1;
this.hasModalOutlet = this.route.outlet === 'modal';
}
MOVE: string;
NEXT: string;
BACK: string;
+ PREVIOUS: string;
+ CREATING: string;
CHANGE: string;
COPY: string;
CLONE: string;
/* Wizard wording */
this.NEXT = $localize`Next`;
this.BACK = $localize`Back`;
+ this.PREVIOUS = $localize`Previous`;
+ this.CREATING = $localize`Creating`;
/* Non-standard actions */
this.CLONE = $localize`Clone`;
export const DEFAULT_SUBVOLUME_GROUP = '_nogroup';
+export const CEPHFS_MIRRORING_URL = '/cephfs/mirroring';
local_fs_name?: string;
client_name: string;
directory_count: number;
+ filesystem_id?: number;
peerId?: string;
id?: string;
}