Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions src/app/pages/product-details/product-details.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,28 @@ <h5 class="flex justify-center mb-2 p-6 text-2xl font-bold tracking-tight text-g
</div>
}
}

@if(usageMetrics.length > 0){
<h2 class="text-4xl font-extrabold text-primary-100 dark:text-primary-50 text-center pb-8 pt-12">Usage metrics</h2>
<div class="container mx-auto px-4">
<div class="flex flex-wrap -mx-4 justify-center">
@for (metric of usageMetrics; track metric.id) {
<div class="w-full md:w-1/2 lg:w-1/3 px-4 mb-8">
<div class="border border-gray-200 rounded-lg shadow bg-white dark:bg-secondary-200 dark:border-gray-800 shadow-md p-8 h-full">
<h3 [ngClass]="{
'break-all': hasLongWord(metric.name, 20),
'break-words': !hasLongWord(metric.name, 20)
}" class="text-2xl font-bold mb-4 dark:text-white break-words">{{metric.name}}</h3>
<markdown [ngClass]="{
'break-all': hasLongWord(metric.description, 20),
'break-words': !hasLongWord(metric.description, 20)
}" class="text-gray-700 dark:text-gray-200 text-wrap break-words" [data]="metric.description"></markdown>
</div>
</div>
}
</div>
</div>
}

</div>

Expand All @@ -443,19 +465,19 @@ <h2 class="text-4xl font-extrabold text-primary-100 text-center pb-8 pt-12 dark:
@if (val?.isDefault == true) {
<div class="flex items-center pl-4">
<input disabled checked id="disabled-checked-checkbox" type="checkbox" value="" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 dark:bg-gray-600 dark:border-gray-800 rounded-full focus:ring-blue-500 focus:ring-2">
@if(val.value){
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 dark:bg-secondary-200 dark:border-gray-800 text-wrap break-words">{{val.value}} ({{val.unitOfMeasure}})</label>
@if(val.value !== undefined && val.value !== null){
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 dark:bg-secondary-200 dark:border-gray-800 text-wrap break-words">{{val.value}}{{val.unitOfMeasure ? ' (' + val.unitOfMeasure + ')' : ''}}</label>
} @else {
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 dark:bg-secondary-200 dark:border-gray-800 text-wrap break-words">{{val.valueFrom}} - {{val.valueTo}} ({{val?.unitOfMeasure}})</label>
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 dark:bg-secondary-200 dark:border-gray-800 text-wrap break-words">{{val.valueFrom}} - {{val.valueTo}}{{val.unitOfMeasure ? ' (' + val.unitOfMeasure + ')' : ''}}</label>
}
</div>
} @else {
<div class="flex items-center pl-4">
<input disabled id="disabled-checkbox" type="checkbox" value="" class="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-600 dark:border-gray-800 border-gray-300 rounded-full focus:ring-blue-500 focus:ring-2">
@if(val.value){
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 text-wrap break-words">{{val.value}} ({{val.unitOfMeasure}})</label>
@if(val.value !== undefined && val.value !== null){
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 text-wrap break-words">{{val.value}}{{val.unitOfMeasure ? ' (' + val.unitOfMeasure + ')' : ''}}</label>
} @else {
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 text-wrap break-words">{{val.valueFrom}} - {{val.valueTo}} ({{val?.unitOfMeasure}})</label>
<label for="disabled-checked-checkbox" class="ms-2 text-sm font-medium text-gray-700 dark:text-gray-200 text-wrap break-words">{{val.valueFrom}} - {{val.valueTo}}{{val.unitOfMeasure ? ' (' + val.unitOfMeasure + ')' : ''}}</label>
}
</div>
}
Expand Down Expand Up @@ -659,5 +681,3 @@ <h2 #relationshipsContent class="text-4xl font-extrabold text-primary-100 text-c
[width]="'w-2/3'"
(closeDrawer)="closeDrawer()">
</app-price-plan-drawer>


74 changes: 74 additions & 0 deletions src/app/pages/product-details/product-details.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { LocalStorageService } from 'src/app/services/local-storage.service';
import { ShoppingCartServiceService } from 'src/app/services/shopping-cart-service.service';
import { EventMessageService } from 'src/app/services/event-message.service';
import { AccountServiceService } from 'src/app/services/account-service.service';
import { UsageServiceService } from 'src/app/services/usage-service.service';
import { environment } from 'src/environments/environment';

describe('ProductDetailsComponent', () => {
Expand All @@ -23,6 +24,7 @@ describe('ProductDetailsComponent', () => {
let cartSpy: jasmine.SpyObj<ShoppingCartServiceService>;
let eventMessageSpy: jasmine.SpyObj<EventMessageService>;
let accountSpy: jasmine.SpyObj<AccountServiceService>;
let usageSpy: jasmine.SpyObj<UsageServiceService>;
let routerSpy: jasmine.SpyObj<Router>;
let locationSpy: jasmine.SpyObj<Location>;
let messages$: Subject<any>;
Expand All @@ -34,6 +36,7 @@ describe('ProductDetailsComponent', () => {
'getProductById',
'getProductSpecification',
'getProductPrice',
'getOfferingPrice',
'getServiceSpec',
'getResourceSpec',
'getComplianceLevel',
Expand All @@ -50,6 +53,7 @@ describe('ProductDetailsComponent', () => {
'emitRemovedCartItem',
]);
accountSpy = jasmine.createSpyObj<AccountServiceService>('AccountServiceService', ['getOrgInfo']);
usageSpy = jasmine.createSpyObj<UsageServiceService>('UsageServiceService', ['getUsageSpec']);
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigate']);
locationSpy = jasmine.createSpyObj<Location>('Location', ['back']);

Expand All @@ -62,6 +66,7 @@ describe('ProductDetailsComponent', () => {
cartSpy.removeItemShoppingCart.and.resolveTo();
cartSpy.getShoppingCart.and.resolveTo([]);
accountSpy.getOrgInfo.and.resolveTo({ id: 'org-1', tradingName: 'Org One' } as any);
usageSpy.getUsageSpec.and.resolveTo({ id: 'usage-1', description: 'Usage spec description' });

await TestBed.configureTestingModule({
schemas: [NO_ERRORS_SCHEMA],
Expand All @@ -74,6 +79,7 @@ describe('ProductDetailsComponent', () => {
{ provide: ShoppingCartServiceService, useValue: cartSpy },
{ provide: EventMessageService, useValue: eventMessageSpy },
{ provide: AccountServiceService, useValue: accountSpy },
{ provide: UsageServiceService, useValue: usageSpy },
{ provide: Router, useValue: routerSpy },
{ provide: Location, useValue: locationSpy },
{
Expand Down Expand Up @@ -380,6 +386,74 @@ describe('ProductDetailsComponent', () => {
expect(component.goToRelationships).not.toHaveBeenCalled();
});

it('loadUsageMetrics should collect linked metrics and deduplicate by usageSpecId + metric name', async () => {
apiSpy.getOfferingPrice.and.callFake(async (id: string) => {
if (id === 'price-comp-1') {
return {
usageSpecId: 'usage-1',
unitOfMeasure: { units: 'RAM_gb_hour' },
description: 'Fallback RAM description',
};
}
return {
usageSpecId: 'usage-2',
unitOfMeasure: { units: 'CPU_core_hour' },
description: 'Fallback CPU description',
};
});
usageSpy.getUsageSpec.and.callFake(async (id: string) => {
if (id === 'usage-1') {
return { id: 'usage-1', description: 'RAM metric description' };
}
return { id: 'usage-2', description: 'CPU metric description' };
});

await component.loadUsageMetrics([
{
bundledPopRelationship: [{ id: 'price-comp-1' }, { id: 'price-comp-2' }, { id: 'price-comp-1' }],
},
]);

expect(component.usageMetrics.length).toBe(2);
expect(component.usageMetrics).toContain(jasmine.objectContaining({
usageSpecId: 'usage-1',
name: 'RAM_gb_hour',
description: 'RAM metric description',
}));
expect(component.usageMetrics).toContain(jasmine.objectContaining({
usageSpecId: 'usage-2',
name: 'CPU_core_hour',
description: 'CPU metric description',
}));
expect(usageSpy.getUsageSpec).toHaveBeenCalledTimes(2);
});

it('loadUsageMetrics should fallback to product offering price description when usage spec cannot be loaded', async () => {
usageSpy.getUsageSpec.and.rejectWith(new Error('cannot load usage spec'));

await component.loadUsageMetrics([
{
usageSpecId: 'usage-1',
unitOfMeasure: { units: 'Requests_hour' },
description: 'Requests per hour description',
},
]);

expect(component.usageMetrics.length).toBe(1);
expect(component.usageMetrics[0].name).toBe('Requests_hour');
expect(component.usageMetrics[0].description).toBe('Requests per hour description');
});

it('loadUsageMetrics should ignore entries without usageSpecId or metric unit name', async () => {
await component.loadUsageMetrics([
{ usageSpecId: 'usage-1' },
{ unitOfMeasure: { units: 'GB_hour' } },
{ usageSpecId: 'usage-2', unitOfMeasure: { amount: 1 } },
] as any[]);

expect(component.usageMetrics).toEqual([]);
});

it('hasLongWord and normalizeName should handle edge cases', () => {
expect(component.hasLongWord('short words', 20)).toBeFalse();
expect(component.hasLongWord('averyveryverylongword', 10)).toBeTrue();
Expand Down
103 changes: 103 additions & 0 deletions src/app/pages/product-details/product-details.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ import { environment } from 'src/environments/environment';
import { Location } from '@angular/common';
import {firstValueFrom, Subject} from "rxjs";
import { takeUntil } from 'rxjs/operators';
import { UsageServiceService } from 'src/app/services/usage-service.service';

interface UsageMetricCard {
id: string;
usageSpecId: string;
name: string;
description: string;
}

@Component({
selector: 'app-product-details',
Expand Down Expand Up @@ -75,6 +83,7 @@ export class ProductDetailsComponent implements OnInit, OnDestroy {
checkCustom:boolean=false;
textDivHeight:any;
prodChars:any[]=[];
usageMetrics: UsageMetricCard[] = [];
selfAtt:any='';

errorMessage:any='';
Expand Down Expand Up @@ -119,6 +128,7 @@ export class ProductDetailsComponent implements OnInit, OnDestroy {
private cartService: ShoppingCartServiceService,
private eventMessage: EventMessageService,
private accService: AccountServiceService,
private usageService: UsageServiceService,
private location: Location
) {
this.showTermsMore=false;
Expand Down Expand Up @@ -256,6 +266,7 @@ export class ProductDetailsComponent implements OnInit, OnDestroy {
}
}
}
await this.loadUsageMetrics(prices);

if(this.prodSpec.productSpecCharacteristic != undefined) {
// Avoid displaying the compliance credential && Avoid showing "- enabled" chars
Expand Down Expand Up @@ -377,6 +388,98 @@ export class ProductDetailsComponent implements OnInit, OnDestroy {
}
}

async loadUsageMetrics(prices: any[] | undefined): Promise<void> {
if (!prices || prices.length === 0) {
this.usageMetrics = [];
return;
}

const metricsMap = new Map<string, UsageMetricCard>();
const usageSpecCache = new Map<string, any>();

for (const price of prices) {
await this.collectUsageMetricsFromPrice(price, metricsMap, usageSpecCache);
}

this.usageMetrics = Array.from(metricsMap.values());
}

private async collectUsageMetricsFromPrice(
price: any,
metricsMap: Map<string, UsageMetricCard>,
usageSpecCache: Map<string, any>
): Promise<void> {
if (!price) {
return;
}

const bundledRelationships = Array.isArray(price.bundledPopRelationship) ? price.bundledPopRelationship : [];
if (bundledRelationships.length > 0) {
for (const relationship of bundledRelationships) {
if (!relationship?.id) {
continue;
}
try {
const linkedPrice = await this.api.getOfferingPrice(relationship.id);
await this.addMetricFromPrice(linkedPrice, metricsMap, usageSpecCache);
} catch (error) {
console.error('Error loading linked product offering price', error);
}
}
return;
}

await this.addMetricFromPrice(price, metricsMap, usageSpecCache);
}

private async addMetricFromPrice(
price: any,
metricsMap: Map<string, UsageMetricCard>,
usageSpecCache: Map<string, any>
): Promise<void> {
const usageSpecId = price?.usageSpecId;
const metricName = this.getMetricName(price?.unitOfMeasure);

if (!usageSpecId || !metricName) {
return;
}

const metricKey = `${usageSpecId}:${metricName}`;
if (metricsMap.has(metricKey)) {
return;
}

let usageSpec = usageSpecCache.get(usageSpecId);
if (usageSpec === undefined) {
try {
usageSpec = await this.usageService.getUsageSpec(usageSpecId);
} catch (error) {
usageSpec = null;
}
usageSpecCache.set(usageSpecId, usageSpec);
}

metricsMap.set(metricKey, {
id: metricKey,
usageSpecId,
name: metricName,
description: usageSpec?.description || price?.description || 'No description available.',
});
}

private getMetricName(unitOfMeasure: any): string {
if (!unitOfMeasure) {
return '';
}
if (typeof unitOfMeasure === 'string') {
return unitOfMeasure;
}
if (typeof unitOfMeasure?.units === 'string') {
return unitOfMeasure.units;
}
return '';
}

toggleQuoteModal(){
//Show quote modal
this.showQuoteModal = true;
Expand Down
Loading
Loading