import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component';
import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component';
import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
+// import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
import { NvmeofGatewayNodeMode } from '~/app/shared/models/nvmeof';
import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
{
path: 'nodes',
component: NvmeofGatewayNodeComponent,
- data: { breadcrumbs: $localize`Gateway nodes`, mode: NvmeofGatewayNodeMode.DETAILS }
+ data: { breadcrumbs: $localize`Overview`, mode: NvmeofGatewayNodeMode.DETAILS }
},
{
path: 'subsystems',
-.breadcrumbs--padding {
- padding-left: 0 !important;
-}
-
-.cds--breadcrumb {
- margin-top: 0;
- padding: var(--cds-spacing-05);
-}
this.groupName = pm.get('group') ?? '';
this.sidebarItems = [
{
- label: $localize`Gateway nodes`,
+ label: $localize`Overview`,
route: [this.basePath, this.groupName, 'nodes'],
routerLinkActiveOptions: { exact: true }
},
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{sm: 4, md: 8}">
- <div class="form-item"
+ <div class="form-item cds-mt-5"
cdsRow>
<cds-combo-box
type="single"
i18n-emptyStateTitle
emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
i18n-emptyStateMessage>
- <cd-table-actions class="table-actions"
- [permission]="permission"
- [selection]="selection"
- [tableActions]="tableActions">
- </cd-table-actions>
+
+ <div class="table-actions">
+ <cd-table-actions [permission]="permission"
+ [selection]="selection"
+ class="btn-group"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
</cd-table>
</ng-container>
<ng-template #gatewayStatusTpl
let-gateway="data.value">
<div [cdsStack]="'horizontal'"
- gap="4">
+ [gap]="4">
@if (gateway.running > 0) {
<span [ngClass]="gateway.error > 0 ? 'cds-mr-3' : ''">
+@use '@carbon/layout';
+
+cd-details-card {
+ display: block;
+ margin-bottom: layout.$spacing-05;
+}
import { Observable, of, Subject } from 'rxjs';
import { HttpClientModule } from '@angular/common/http';
import { SharedModule } from '~/app/shared/shared.module';
-import { Router } from '@angular/router';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component';
declarations: [NvmeofGatewayGroupComponent],
providers: [
{ provide: NvmeofService, useValue: nvmeofServiceSpy },
- {
- provide: Router,
- useValue: { navigate: jest.fn() }
- },
{
provide: ModalCdsService,
useValue: { show: jest.fn() }
});
});
+ describe('View details action', () => {
+ it('should use routerLink and navigate to the resource page for the selected group', () => {
+ component.selection.first = jest.fn().mockReturnValue({ name: 'default' });
+ const viewAction = component.tableActions.find((a) => a.name === 'View details');
+ expect(viewAction).toBeTruthy();
+ expect(viewAction!.click).toBeUndefined();
+ const link = (viewAction!.routerLink as Function)();
+ expect(link).toBe('/block/nvmeof/gateways/view/default');
+ });
+
+ it('should set canBePrimary true for single selection only', () => {
+ const viewAction = component.tableActions.find((a) => a.name === 'View details');
+ const single = { hasSingleSelection: true } as any;
+ const multi = { hasSingleSelection: false } as any;
+ expect((viewAction!.canBePrimary as Function)(single)).toBe(true);
+ expect((viewAction!.canBePrimary as Function)(multi)).toBe(false);
+ });
+ });
+
describe('Delete Flow with/without Subsystems', () => {
let mockGroup: any;
import { Permission } from '~/app/shared/models/permissions';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { Icons, IconSize } from '~/app/shared/enum/icons.enum';
-import { NvmeofGatewayGroup, NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
import { CephServiceSpec } from '~/app/shared/models/service.interface';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { CephServiceService } from '~/app/shared/api/ceph-service.service';
private destroy$ = new Subject<void>();
@ViewChild(TableComponent, { static: true })
- table: TableComponent;
+ table!: TableComponent;
@ViewChild('dateTpl', { static: true })
- dateTpl: TemplateRef<any>;
+ dateTpl!: TemplateRef<any>;
@ViewChild('customTableItemTemplate', { static: true })
- customTableItemTemplate: TemplateRef<any>;
+ customTableItemTemplate!: TemplateRef<any>;
@ViewChild('deleteTpl', { static: true })
- deleteTpl: TemplateRef<any>;
+ deleteTpl!: TemplateRef<any>;
@ViewChild('gatewayStatusTpl', { static: true })
- gatewayStatusTpl: TemplateRef<any>;
+ gatewayStatusTpl!: TemplateRef<any>;
- permission: Permission;
- tableActions: CdTableAction[];
+ permission!: Permission;
+ tableActions: CdTableAction[] = [];
nodesAvailable = false;
columns: CdTableColumn[] = [];
selection: CdTableSelection = new CdTableSelection();
- gatewayGroup$: Observable<CephServiceSpec[]>;
+ gatewayGroup$: Observable<CephServiceSpec[]> = of([]);
subject = new BehaviorSubject<CephServiceSpec[]>([]);
- context: CdTableFetchDataContext;
- gatewayGroupName: string;
- subsystemCount: number;
- gatewayCount: number;
+ context!: CdTableFetchDataContext;
+ gatewayGroupName = '';
+ subsystemCount = 0;
+ gatewayCount = 0;
private lastGroupCount = 0;
viewUrl = `/${BASE_URL}/view`;
const viewAction: CdTableAction = {
permission: 'read',
icon: Icons.eye,
- click: () => this.getViewDetails(),
+ routerLink: () => `${this.viewUrl}/${this.selection.first()?.name}`,
name: $localize`View details`,
- canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+ canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection
};
const deleteAction: CdTableAction = {
return of([]);
}
return forkJoin(
- groups.map((group: NvmeofGatewayGroup) => {
+ groups.map((group: CephServiceSpec) => {
const isRunning = (group.status?.running ?? 0) > 0;
const subsystemsObservable = isRunning
? this.nvmeofService.listSubsystems(group.spec.group).pipe(
this.nvmeofService
.listSubsystems(group)
.pipe(catchError(() => of([])))
- .subscribe((subsystems: NvmeofSubsystem[]) => {
+ .subscribe((subsystems: any) => {
let subsList: NvmeofSubsystem[] = [];
if (subsystems) {
const rawList = Array.isArray(subsystems) ? subsystems : [subsystems];
}
);
}
-
getViewDetails() {
const selectedGroup = this.selection.first();
if (!selectedGroup) {
-<cds-modal
- [open]="open"
- (overlaySelected)="closeModal()"
- [size]="'lg'">
- <cds-modal-header
- (closeSelect)="closeModal()">
- <h4
- class="cds--type-heading-04 cds-ml-3"
- i18n>
+<cds-modal [open]="open"
+ (overlaySelected)="closeModal()"
+ [size]="'md'">
+ <cds-modal-header (closeSelect)="closeModal()">
+ <h4 class="cds--type-heading-04"
+ i18n>
Add gateway nodes
</h4>
- <p
- class="cds--type-body-compact-01 cds-ml-3"
- i18n>
+ <p class="cds--type-body-01"
+ i18n>
Select NVMe-oF gateway nodes to associate with this gateway group.
</p>
</cds-modal-header>
<section cdsModalContent>
- <div class="cds-mt-5">
- <div
- cdsStack="vertical"
- gap="1">
- <div
- class="cds--type-heading-01"
- i18n>
- Select gateway nodes
- </div>
- <div
- class="cds--type-label-01"
- i18n>
- Nodes to run NVMe-oF target service
- </div>
- </div>
+ <div class="cds-ml-5 cds-mt-5">
+ <p class="cds--type-heading-01" i18n>Select gateway nodes</p>
+ <p class="cds--type-label-01" i18n>Nodes to run NVMe-oF target pods/services</p>
+ </div>
- <cd-table
- (fetchData)="getHosts($event)"
- selectionType="multiClick"
- [searchableObjects]="true"
- [data]="hosts"
- [columns]="columns"
- (updateSelection)="updateSelection($event)"
- [autoReload]="false">
- </cd-table>
+ <!-- Table on white background -->
+ <cd-table (fetchData)="getHosts($event)"
+ selectionType="multiClick"
+ [searchableObjects]="true"
+ [data]="hosts"
+ [columns]="columns"
+ (updateSelection)="updateSelection($event)"
+ [autoReload]="false">
+ </cd-table>
- <ng-template
- #addrTemplate
- let-value="data.value">
- <span>{{ value || '-' }}</span>
- </ng-template>
+ <ng-template #addrTemplate
+ let-value="data.value">
+ <span>{{ value || '-' }}</span>
+ </ng-template>
- <ng-template
- #statusTemplate
- let-value="data.value"
- let-row="data.row">
- <div
- class="status-cell"
- cdsStack="horizontal"
- gap="3">
+ <ng-template #statusTemplate
+ let-value="data.value"
+ let-row="data.row">
+ <div class="status-cell"
+ cdsStack="horizontal"
+ gap="3">
@if (value === HostStatus.AVAILABLE) {
- <cd-icon type="success"></cd-icon>
+ <cd-icon type="success"></cd-icon>
}
<span>{{ value | titlecase }}</span>
- </div>
- </ng-template>
+ </div>
+ </ng-template>
- <ng-template
- #labelsTemplate
- let-value="data.value">
+ <ng-template #labelsTemplate
+ let-value="data.value">
@if (value && value.length > 0) {
- <cds-tag
- *ngFor="let label of value"
- class="tag tag-dark">{{ label }}</cds-tag>
+ <cds-tag *ngFor="let label of value"
+ class="tag tag-dark">{{ label }}</cds-tag>
} @else {
- <span>-</span>
+ <span>-</span>
}
- </ng-template>
+ </ng-template>
- </div>
</section>
- <cd-form-button-panel
- [modalForm]="true"
- [showSubmit]="true"
- submitText="Add"
- i18n-submitText
- [disabled]="selection.selected.length === 0"
- (submitActionEvent)="onSubmit()"
- (backActionEvent)="closeModal()">
+ <cd-form-button-panel [modalForm]="true"
+ [showSubmit]="true"
+ submitText="Add"
+ i18n-submitText
+ [disabled]="selection.selected.length === 0"
+ (submitActionEvent)="onSubmit()"
+ (backActionEvent)="closeModal()">
</cd-form-button-panel>
-</cds-modal>
+</cds-modal>
\ No newline at end of file
-cds-modal-header {
- border-bottom: 1px solid var(--cds-ui-03, #e0e0e0);
-}
+cd-nvmeof-gateway-node-add-modal {
+ .cds--modal-container {
+ height: 80vh;
+ max-height: 80vh;
+ }
+
+ .cds--modal-header {
+ border-bottom: none;
+ }
+
+ .cds--modal-content {
+ background-color: var(--cds-background);
+ padding: 0;
+ }
-.status-cell {
- align-items: center;
+ cd-table {
+ display: block;
+ background-color: var(--cds-layer);
+ border: 1px solid var(--cds-border-subtle-01);
+ margin: 0 var(--cds-spacing-05) var(--cds-spacing-05);
+ }
}
ViewChild,
TemplateRef,
OnDestroy,
- Inject
+ Inject,
+ ViewEncapsulation
} from '@angular/core';
import { Subscription } from 'rxjs';
selector: 'cd-nvmeof-gateway-node-add-modal',
templateUrl: './nvmeof-gateway-node-add-modal.component.html',
styleUrls: ['./nvmeof-gateway-node-add-modal.component.scss'],
- standalone: false
+ standalone: false,
+ encapsulation: ViewEncapsulation.None
})
export class NvmeofGatewayNodeAddModalComponent extends CdForm implements OnInit, OnDestroy {
hosts: Host[] = [];
-<div>
- <cd-table
- #table
- [data]="hosts"
- [columns]="columns"
- columnMode="flex"
- (fetchData)="getHosts($event)"
- [selectionType]="selectionType"
- [searchableObjects]="true"
- [serverSide]="false"
- [maxLimit]="25"
- identifier="hostname"
- forceIdentifier="true"
- (updateSelection)="updateSelection($event)"
- emptyStateTitle="No nodes available"
- i18n-emptyStateTitle
- emptyStateMessage="Add your first gateway node to start using NVMe over Fabrics. Nodes provide the resources required to expose NVMe/TCP block storage."
- i18n-emptyStateMessage
- >
- <cd-table-actions
- class="table-actions"
- [permission]="permission"
- [selection]="selection"
+<div class="content-theme">
+ @if (mode === 'details' && groupName) {
+ <cd-details-card cardTitle="Details" i18n-cardTitle
+ [details]="gatewayDetails"
+ class="cds-mb-5">
+ </cd-details-card>
+ }
+
+ @if (mode === 'details') {
+ <div class="cds-mb-5">
+ <h2 class="cds--type-heading-03 cds-mt-5" i18n>Gateway nodes</h2>
+ <p class="cds--type-body-compact-01" i18n>Nodes running NVMe-oF target pods/services</p>
+ </div>
+ }
+ <cd-table #table
+ [data]="hosts"
+ [columns]="columns"
+ columnMode="flex"
+ (fetchData)="getHosts($event)"
+ [selectionType]="selectionType"
+ [searchableObjects]="true"
+ [serverSide]="false"
+ [maxLimit]="25"
+ identifier="hostname"
+ [forceIdentifier]="true"
+ (updateSelection)="updateSelection($event)"
+ emptyStateTitle="No nodes available" i18n-emptyStateTitle
+ emptyStateMessage="Add your first gateway node to start using NVMe over Fabrics. Nodes provide the resources required to expose NVMe/TCP block storage."
+ i18n-emptyStateMessage>
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
[tableActions]="tableActions">
</cd-table-actions>
</cd-table>
</div>
-<ng-template
- #hostNameTpl
- let-value="data.value"
->
+<ng-template #hostNameTpl
+ let-value="data.value">
<span class="cds-ml-2">{{ value }}</span>
</ng-template>
-<ng-template
- #addrTpl
- let-value="data.value"
->
+<ng-template #addrTpl let-value="data.value">
<span>{{ value || '-' }}</span>
</ng-template>
-<ng-template
- #statusTpl
- let-value="data.value"
- let-row="data.row"
->
-@if (value) {
- <div
- [cdsStack]="'horizontal'"
- gap="4"
- >
- @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) {
+<ng-template #statusTpl
+ let-value="data.value"
+ let-row="data.row">
+ @if (value) {
+ <div [cdsStack]="'horizontal'" [gap]="4">
+ @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) {
<cd-icon type="success"></cd-icon>
- } @else {
+ } @else {
<cd-icon type="error"></cd-icon>
- }
- <span class="cds-ml-3">{{ value | titlecase }}</span>
+ }
+ <span class="cds-ml-3">{{ value | titlecase }}</span>
</div>
-} @else {
+ } @else {
<span>-</span>
-}
+ }
</ng-template>
-<ng-template
- #labelsTpl
- let-value="data.value"
->
-@if (value && value.length > 0) {
- <cds-tag *ngFor="let label of value"
- class="tag tag-dark">{{ label }}</cds-tag>
-} @else {
+<ng-template #labelsTpl let-value="data.value">
+ @if (value && value.length > 0) {
+ <cds-tag *ngFor="let label of value" class="tag tag-dark">{{ label }}</cds-tag>
+ } @else {
<span>-</span>
-}
-</ng-template>
-
-
+ }
+</ng-template>
\ No newline at end of file
import { FinishedTask } from '~/app/shared/models/finished-task';
import { NotificationService } from '~/app/shared/services/notification.service';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { DetailItem } from '~/app/shared/components/details-card/details-card.component';
@Component({
selector: 'cd-nvmeof-gateway-node',
usedHostnames: Set<string> = new Set();
serviceSpec: CephServiceSpec | undefined;
hasAvailableHosts = false;
+ gatewayDetails: DetailItem[] = [];
permission: Permission;
columns: CdTableColumn[] = [];
} else {
this.hosts = [];
}
+
+ this.gatewayDetails = this.buildGatewayDetails(this.serviceSpec, this.hosts.length);
}
this.count = this.hosts.length;
this.hostsLoaded.emit(this.count);
}
+
+ private buildGatewayDetails(
+ serviceSpec: CephServiceSpec,
+ gatewayNodeCount: number
+ ): DetailItem[] {
+ return [
+ {
+ label: $localize`Gateway name`,
+ value: serviceSpec.spec?.group || this.groupName || '-'
+ },
+ {
+ label: $localize`Gateway nodes`,
+ value: gatewayNodeCount
+ },
+ {
+ label: $localize`Encryption`,
+ value: serviceSpec.spec?.enable_auth ? $localize`Enabled` : $localize`Disabled`,
+ type: 'status',
+ statusIcon: serviceSpec.spec?.enable_auth ? 'success' : 'error'
+ },
+ {
+ label: $localize`mTLS`,
+ value: $localize`Disabled`,
+ type: 'status',
+ statusIcon: 'error'
+ }
+ ];
+ }
}
</div>
</div>
- <!-- CDS Input (shown when checkbox is checked) -->
+ <!-- Encryption Configuration (shown when checkbox is checked) -->
@if (groupForm.controls.enableEncryption.value) {
<div cdsRow
class="form-item">
<div cdsCol>
<cds-textarea-label
- labelInputID="cdsInput"
+ labelInputID="encryptionConfig"
helperText="Provide the encryption configuration details."
+ cdRequiredField="Encryption configuration"
i18n
- i18n-helperText>Encryption configuration
+ i18n-helperText
+ [invalid]="encryptionConfigRef.isInvalid">Encryption configuration
<textarea cdsTextArea
- id="cdsInput"
+ id="encryptionConfig"
cdValidate
- #encryptionConfig="cdValidate"
- [invalid]="encryptionConfig.invalid"
- [invalidText]="encryptionConfigError"
+ #encryptionConfigRef="cdValidate"
+ [invalid]="encryptionConfigRef.isInvalid"
formControlName="encryptionConfig"
cols="100"
rows="4">
</textarea>
</cds-textarea-label>
+ <span
+ class="invalid-feedback"
+ *ngIf="groupForm.showError('encryptionConfig', formDir, 'required')"
+ i18n>This field is required.</span>
</div>
</div>
- <ng-template #encryptionConfigError>
- <span *ngIf="groupForm.controls.encryptionConfig.hasError('required')"
- class="invalid-feedback"
- i18n>This field is required.</span>
- </ng-template>
}
<!-- Target Nodes Selection -->
<div
standalone: false
})
export class NvmeofGroupFormComponent extends CdForm implements OnInit {
- @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
+ @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent!: NvmeofGatewayNodeComponent;
permission: Permission;
- groupForm: CdFormGroup;
- action: string;
+ groupForm!: CdFormGroup;
+ action!: string;
resource: string;
group: string;
pageURL: string;
<cds-text-label
i18n
[invalid]="nqnRef.isInvalid"
- [invalidText]="nqnInvalidTemplate"
helperText="A unique identifier for the subsystem."
i18n-helperText>
Subsystem NQN (NVMe Qualified Name)
formControlName="nqn"
[invalid]="nqnRef.isInvalid">
</cds-text-label>
+ @if (nqnRef.isInvalid) {
+ @for (err of formGroup.get('nqn')?.errors | keyvalue; track err.key) {
+ <span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+ }
+ }
</div>
<div cdsRow
class="form-item">
it('should set first group as default initially', () => {
expect(component.group).toBe('default');
});
+
+ it('should clear selected group and stop fetching subsystems', () => {
+ component.group = 'default';
+
+ component.onGroupChange(null);
+
+ expect(component.group).toBeNull();
+ });
});
padding-right: 0;
.breadcrumbs--padding {
- padding-left: var(--cds-spacing-07);
+ padding-left: var(--cds-spacing-05);
}
}
}
import { PageHeaderComponent } from './page-header/page-header.component';
import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component';
import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.component';
+import { ProductiveCardComponent } from './productive-card/productive-card.component';
@NgModule({
imports: [
CommonModule,
+ ProductiveCardComponent,
FormsModule,
ReactiveFormsModule,
NgbAlertModule,
TearsheetStepComponent,
PageHeaderComponent,
SidebarLayoutComponent,
- NumberWithUnitComponent
+ NumberWithUnitComponent,
+ ProductiveCardComponent
]
})
export class ComponentsModule {
-<cds-tile>
-@if (cardTitle) {
- <div class="details-card-header">
- <span class="cds--type-heading-03"
- i18n>{{ cardTitle }}</span>
- </div>
-}
+<cd-productive-card>
+ <ng-template #header>
+ @if (cardTitle) {
+ <h3 class="cds--type-heading-03" i18n>{{ cardTitle }}</h3>
+ }
+ </ng-template>
- <div class="details-card-body">
- <div cdsRow>
- <ng-content select=".details-body-content"></ng-content>
+ <div [cdsStack]="'vertical'"
+ [gap]="0">
+ @if (details && details.length > 0) {
+ <div [cdsStack]="'horizontal'"
+ [gap]="6">
+ @for (detail of getVisibleDetails(); track detail.label) {
+ <div [cdsStack]="'vertical'"
+ [gap]="2">
+ <label class="cds--type-label-01">{{ detail.label }}</label>
+ <div class="cds--type-body-01">
+ @switch (detail.type) {
+ @case ('status') {
+ <div [cdsStack]="'horizontal'"
+ [gap]="3">
+ <cd-icon [type]="getStatusIcon(detail)"></cd-icon>
+ <span>{{ getDisplayValue(detail.value) }}</span>
+ </div>
+ }
+
+ @default {
+ <span>{{ getDisplayValue(detail.value) }}</span>
+ }
+ }
+ </div>
+ </div>
+ }
</div>
+ }
+ <ng-content></ng-content>
</div>
-</cds-tile>
+</cd-productive-card>
-@use '@carbon/layout';
-
-.details-card-header {
- align-items: center;
- display: flex;
- justify-content: space-between;
- padding-bottom: layout.$spacing-05;
- position: relative;
-}
-
-::ng-deep .cds--label {
- display: block;
-}
-
-.cds--row {
- row-gap: layout.$spacing-05;
-}
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('should filter hidden details', () => {
+ component.details = [
+ { label: 'Visible', value: 'test', type: 'text' },
+ { label: 'Hidden', value: 'test', type: 'text', hidden: true }
+ ];
+ const visible = component.getVisibleDetails();
+ expect(visible.length).toBe(1);
+ expect(visible[0].label).toBe('Visible');
+ });
+
+ it('should detect disabled status', () => {
+ expect(component.isStatusDisabled('Disabled')).toBe(true);
+ expect(component.isStatusDisabled('disabled')).toBe(true);
+ expect(component.isStatusDisabled('Enabled')).toBe(false);
+ });
+
+ it('should handle empty details array', () => {
+ component.details = [];
+ const visible = component.getVisibleDetails();
+ expect(visible.length).toBe(0);
+ });
+
+ it('should handle undefined details', () => {
+ component.details = undefined;
+ const visible = component.getVisibleDetails();
+ expect(visible.length).toBe(0);
+ });
});
-import { Component, Input } from '@angular/core';
+import { Component, Input, TemplateRef } from '@angular/core';
+
+type DetailValue = any;
+type DetailTemplateContext = { $implicit: DetailValue; detail: DetailItem };
+
+export interface DetailItem {
+ label: string;
+ value: DetailValue;
+ type?: 'text' | 'status' | 'custom';
+ statusIcon?: 'success' | 'error' | 'warning' | 'notification';
+ customTemplate?: TemplateRef<DetailTemplateContext>;
+ hidden?: boolean;
+}
@Component({
selector: 'cd-details-card',
templateUrl: './details-card.component.html',
- styleUrl: './details-card.component.scss',
+ styleUrls: ['./details-card.component.scss'],
standalone: false
})
export class DetailsCardComponent {
@Input()
- cardTitle: string;
+ cardTitle?: string;
+
+ @Input()
+ details?: DetailItem[] = [];
+
+ @Input()
+ columns = 4;
+
+ getVisibleDetails(): DetailItem[] {
+ return (this.details || []).filter((detail) => !detail.hidden);
+ }
+
+ getDetailContext(detail: DetailItem): DetailTemplateContext {
+ return {
+ $implicit: detail.value,
+ detail
+ };
+ }
+
+ getStatusIcon(detail: DetailItem): 'success' | 'error' | 'warning' | 'notification' {
+ if (detail.statusIcon) {
+ return detail.statusIcon;
+ }
+ return this.isStatusDisabled(detail.value) ? 'error' : 'success';
+ }
+
+ hasValue(value: DetailValue): boolean {
+ return value !== null && value !== undefined && value !== '';
+ }
+
+ getDisplayValue(value: DetailValue): DetailValue | string {
+ return this.hasValue(value) ? value : '-';
+ }
+
+ isStatusDisabled(value: DetailValue): boolean {
+ if (value === null || value === undefined) {
+ return false;
+ }
+ const normalized = String(value).trim().toLowerCase();
+ return normalized === 'disabled';
+ }
}
.arrowDown-icon {
fill: theme.$support-error !important;
}
+
+.edit-icon {
+ fill: var(--cds-link-primary) !important;
+}
[ngClass]="{'productive-card--shadow': applyShadow}"
[cdsLayer]="0">
<div class="productive-card-header">
- <ng-container *ngTemplateOutlet="headerTemplate"></ng-container>
+ <ng-container *ngTemplateOutlet="headerTemplate || null"></ng-container>
</div>
<section class="productive-card-section cds--type-body-compact-01"
[ngClass]="{'productive-card-section--footer': footerTemplate}">
</section>
@if(!!footerTemplate) {
<footer class="productive-card-footer">
- <ng-container *ngTemplateOutlet="footerTemplate"></ng-container>
+ <ng-container *ngTemplateOutlet="footerTemplate || null"></ng-container>
</footer>
}
</cds-tile>
<div class="sidebar-layout-shell">
<cds-sidenav
[expanded]="true"
- class="sidebar-layout-nav">
+ class="sidebar-layout-nav cds--side-nav--fixed">
@for (item of items; track item.label) {
<cds-sidenav-item
[route]="item.route"
.sidebar-layout-nav {
background-color: var(--cds-layer-03);
+
+ .cds--side-nav__icon:not(.cds--side-nav__submenu-chevron) {
+ display: none;
+ }
}
.sidebar-layout-main {