diff --git a/src/app/pages/product-details/product-details.component.html b/src/app/pages/product-details/product-details.component.html index c1bea892..75c494f6 100644 --- a/src/app/pages/product-details/product-details.component.html +++ b/src/app/pages/product-details/product-details.component.html @@ -423,6 +423,28 @@
Usage metrics
+
+
+ @for (metric of usageMetrics; track metric.id) { +
+
+

{{metric.name}}

+ +
+
+ } +
+
+ } @@ -443,19 +465,19 @@

- @if(val.value){ - + @if(val.value !== undefined && val.value !== null){ + } @else { - + } } @else {
- @if(val.value){ - + @if(val.value !== undefined && val.value !== null){ + } @else { - + }
} @@ -659,5 +681,3 @@

- - diff --git a/src/app/pages/product-details/product-details.component.spec.ts b/src/app/pages/product-details/product-details.component.spec.ts index 05328452..7c454d00 100644 --- a/src/app/pages/product-details/product-details.component.spec.ts +++ b/src/app/pages/product-details/product-details.component.spec.ts @@ -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', () => { @@ -23,6 +24,7 @@ describe('ProductDetailsComponent', () => { let cartSpy: jasmine.SpyObj; let eventMessageSpy: jasmine.SpyObj; let accountSpy: jasmine.SpyObj; + let usageSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; let locationSpy: jasmine.SpyObj; let messages$: Subject; @@ -34,6 +36,7 @@ describe('ProductDetailsComponent', () => { 'getProductById', 'getProductSpecification', 'getProductPrice', + 'getOfferingPrice', 'getServiceSpec', 'getResourceSpec', 'getComplianceLevel', @@ -50,6 +53,7 @@ describe('ProductDetailsComponent', () => { 'emitRemovedCartItem', ]); accountSpy = jasmine.createSpyObj('AccountServiceService', ['getOrgInfo']); + usageSpy = jasmine.createSpyObj('UsageServiceService', ['getUsageSpec']); routerSpy = jasmine.createSpyObj('Router', ['navigate']); locationSpy = jasmine.createSpyObj('Location', ['back']); @@ -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], @@ -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 }, { @@ -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(); diff --git a/src/app/pages/product-details/product-details.component.ts b/src/app/pages/product-details/product-details.component.ts index 9d5267b0..7c78cf8b 100644 --- a/src/app/pages/product-details/product-details.component.ts +++ b/src/app/pages/product-details/product-details.component.ts @@ -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', @@ -75,6 +83,7 @@ export class ProductDetailsComponent implements OnInit, OnDestroy { checkCustom:boolean=false; textDivHeight:any; prodChars:any[]=[]; + usageMetrics: UsageMetricCard[] = []; selfAtt:any=''; errorMessage:any=''; @@ -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; @@ -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 @@ -377,6 +388,98 @@ export class ProductDetailsComponent implements OnInit, OnDestroy { } } + async loadUsageMetrics(prices: any[] | undefined): Promise { + if (!prices || prices.length === 0) { + this.usageMetrics = []; + return; + } + + const metricsMap = new Map(); + const usageSpecCache = new Map(); + + for (const price of prices) { + await this.collectUsageMetricsFromPrice(price, metricsMap, usageSpecCache); + } + + this.usageMetrics = Array.from(metricsMap.values()); + } + + private async collectUsageMetricsFromPrice( + price: any, + metricsMap: Map, + usageSpecCache: Map + ): Promise { + 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, + usageSpecCache: Map + ): Promise { + 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; diff --git a/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.html b/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.html index e04f4d37..ad743e8a 100644 --- a/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.html +++ b/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.html @@ -30,10 +30,10 @@

{{ 'CREATE_PROD_SPEC._new' | translate }}

    @for (step of steps; track i; let i = $index) { -
  1. {{ 'CR - + @@ -111,9 +111,9 @@

    {{ 'CR {{ 'OFFERINGS._no_prod' | translate }} - + }@else{ -
    +
    @@ -162,7 +162,7 @@

    {{ 'CR }

    @@ -213,7 +213,7 @@

    {{ selfAtt ? selfAtt.productSpecCharacteristicValue[0].value : 'Upload your self attestation document' }}

    - +
    + + }
    @@ -259,8 +259,8 @@

    } } - -
    + +
    @@ -287,7 +287,7 @@

    } @empty { @@ -323,10 +323,10 @@

    -
    {{ 'CREATE_PROD_SPEC._file_check' | translate }} @@ -351,7 +351,7 @@

    You added the following additional certifications:

    -
    +

    {{sel.url}} + class="mb-2 bg-gray-50 dark:bg-secondary-300 border border-2 border-gray-300 dark:border-secondary-200 dark:text-white text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" /> --> +
    @@ -376,14 +376,14 @@

    {{normalizeName(cert.name)}}

    - } @@ -397,7 +397,7 @@

    Add an additional certification + } @else { @@ -405,12 +405,12 @@

    Add an additional certification:

    -
    +
    + class="mb-2 bg-gray-50 border border-gray-300 text-gray-900 dark:bg-secondary-300 dark:border-secondary-200 dark:text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" />
    @if(isoToCreate==''){ @@ -422,11 +422,11 @@

    {{ 'UPDATE_PROD_SPEC._drop_files' | translate }} -

    +

    - + } @else { } @@ -448,16 +448,16 @@

    Save + -

    } - + } @if ((currentStep === 2 && !BUNDLE_ENABLED) || (currentStep === 3 && BUNDLE_ENABLED)) { @if (prodChars.length === 0){ @@ -473,7 +473,7 @@

    } @else { -
    +

    - {{cert.url}} + {{cert.url}} + + +
    @@ -505,7 +505,7 @@

    'break-all': hasLongWord(prod.description, 20), 'break-words': !hasLongWord(prod.description, 20) }" class="hidden lg:table-cell px-6 py-4 text-wrap break-words"> - {{prod.description}} + {{prod.description}}

    } }
    @for (char of prod.productSpecCharacteristicValue; track char; let last=$last) { @@ -522,31 +522,31 @@

    } @else { {{char.value}} } - } + } } @else { @if(!last){ {{char.valueFrom}} - {{char.valueTo}} ({{char?.unitOfMeasure}}), } @else { {{char.valueFrom}} - {{char.valueTo}} ({{char?.unitOfMeasure}}) } - } - } + } + }

    + +
    -
    +
    } - @if(showCreateChar==false){ + @if(!showCreateChar){
    -
    @@ -576,40 +577,42 @@

    -
    - - -
    - @if(isOptional){ -
    - - + @if(charTypeSelected !== 'boolean'){ +
    + +
    + @if(isOptional){ +
    + + +
    + } }
    - @if(creatingChars.length>0){ + @if(creatingChars.length > 0 && charTypeSelected !== 'boolean'){
    - @if(rangeCharSelected){ + @if(charTypeSelected === 'range'){ @for (char of creatingChars; track char; let idx = $index) {
    - - +
    } @@ -617,26 +620,26 @@

    @for (char of creatingChars; track char; let idx = $index) {
    - - @if(numberCharSelected){ + @if(charTypeSelected === 'number'){ } @else { - } + }
    } }

    } - - @if(stringCharSelected){ + + @if(charTypeSelected === 'string'){
    @@ -646,7 +649,7 @@

    - } @else if (numberCharSelected){ + } @else if (charTypeSelected === 'number'){
    @@ -669,7 +672,15 @@

    - } @else if (rangeCharSelected && creatingChars.length==0){ + } @else if (charTypeSelected === 'boolean'){ +
    + + +
    + } @else if (charTypeSelected === 'range' && creatingChars.length==0){
    @@ -707,7 +718,7 @@

    - +
    } +
    --> } - + @if ((currentStep === 3 && !BUNDLE_ENABLED) || (currentStep === 4 && BUNDLE_ENABLED)) { @if(loadingResourceSpec){
    @@ -740,7 +751,7 @@

    {{ 'OFFERINGS._no_res' | translate }}

    -
    + }@else{
    @@ -781,7 +792,7 @@

    }

    @@ -842,7 +853,7 @@

    {{ 'OFFERINGS._no_serv' | translate }} - + }@else{
    @@ -883,7 +894,7 @@

    }

    @@ -931,7 +942,7 @@

    {{ 'CREATE_PROD_SPEC._add_pro - + - + @if(showImgPreview){
    } @else { @@ -963,7 +974,7 @@

    {{ 'CREATE_PROD_SPEC._fi

    {{ 'CREATE_PROD_SPEC._drop_files' | translate }} -

    +

    @@ -978,7 +989,7 @@

    {{ 'CREATE_PROD_SPEC._fi
    - +
    {{ 'CREATE_PROD_SPEC._fi class="w-full bg-gray-50 border border-gray-300 text-gray-900 dark:bg-secondary-300 dark:border-secondary-200 dark:text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5" [ngClass]="attImageName.invalid && attImageName.touched ? 'border-red-500' : 'dark:border-secondary-200'" /> - +
    - - + + }

    {{ 'CREATE_PROD_SPEC._add_att' | translate }}

    @if(prodAttachments.length == 0){ @@ -1016,7 +1027,7 @@

    {{ 'CREATE_PROD_SPEC._add_att } @else { -
    +
    @@ -1041,20 +1052,20 @@

    {{ 'CREATE_PROD_SPEC._add_att {{att.name}}

    }
    - {{att.url}} + {{att.url}} + +
    -
    +
    } @if(showNewAtt==false){
    @@ -1062,17 +1073,17 @@

    {{ 'CREATE_PROD_SPEC._add_att {{ 'CREATE_PROD_SPEC._new_att' | translate }} +

    } @else {
    -
    +
    + class="mb-2 bg-gray-50 dark:bg-secondary-300 border border-gray-300 dark:border-secondary-200 dark:text-white text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" />
    @if(attachToCreate.url==''){ @@ -1084,7 +1095,7 @@

    {{ 'CREATE_PROD_SPEC._add_att

    {{ 'CREATE_PROD_SPEC._drop_files' | translate }} -

    +

    @@ -1098,7 +1109,7 @@

    {{ 'CREATE_PROD_SPEC._add_att

    } @@ -1111,7 +1122,7 @@

    {{ 'CREATE_PROD_SPEC._add_att {{ 'CREATE_PROD_SPEC._save_att' | translate }} +

    @@ -1123,7 +1134,7 @@

    {{ 'CREATE_PROD_SPEC._add_att - --> + --> } @if ((currentStep === 6 && !BUNDLE_ENABLED) || (currentStep === 7 && BUNDLE_ENABLED)) {
    @@ -1140,7 +1151,7 @@

    {{ 'CREATE_PROD_SPEC._add_att

    } @else { -
    +
    @@ -1185,7 +1196,7 @@

    {{ 'CREATE_PROD_SPEC._add_att }