From f2661005cd24afc54073ba9b2bcc0597c0b2a007 Mon Sep 17 00:00:00 2001 From: Francisco de la Vega Date: Tue, 23 Jun 2026 12:56:07 +0200 Subject: [PATCH 1/6] Show embedded analytics page --- angular.json | 3 +- cypress/support/constants.ts | 12 + package-lock.json | 17 ++ package.json | 1 + src/app/app-routing.module.ts | 5 + src/app/app.module.ts | 2 + src/app/data/featuresConfig.ts | 116 +++++++++ src/app/pages/admin/admin.component.html | 11 +- src/app/pages/admin/admin.component.ts | 53 +++++ .../features-config.component.css | 1 + .../features-config.component.html | 73 ++++++ .../features-config.component.ts | 182 ++++++++++++++ .../pages/analytics/analytics.component.css | 44 ++++ .../pages/analytics/analytics.component.html | 90 +++++++ .../pages/analytics/analytics.component.ts | 222 ++++++++++++++++++ src/app/services/app-init.service.ts | 39 ++- src/app/shared/header/header.component.html | 4 +- src/environments/environment.development.ts | 2 + src/environments/environment.production.ts | 2 + src/environments/environment.ts | 2 + 20 files changed, 865 insertions(+), 16 deletions(-) create mode 100644 src/app/data/featuresConfig.ts create mode 100644 src/app/pages/admin/features-config/features-config.component.css create mode 100644 src/app/pages/admin/features-config/features-config.component.html create mode 100644 src/app/pages/admin/features-config/features-config.component.ts create mode 100644 src/app/pages/analytics/analytics.component.css create mode 100644 src/app/pages/analytics/analytics.component.html create mode 100644 src/app/pages/analytics/analytics.component.ts diff --git a/angular.json b/angular.json index 5c84c3d5..ede0e64e 100644 --- a/angular.json +++ b/angular.json @@ -30,7 +30,8 @@ "scripts": [], "allowedCommonJsDependencies": [ "moment", - "currencies.json" + "currencies.json", + "@superset-ui/embedded-sdk" ] }, "configurations": { diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index fbffff78..fba19de7 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -18,6 +18,18 @@ export const init_config = { 'matomoId': '', 'matomoUrl': '', 'searchEnabled': true, + 'analyticsSupersetDomain': 'https://analytics.dome-marketplace-sbx.org', + 'features': { + 'searchEnabled': true, + 'purchaseEnabled': false, + 'quotesEnabled': false, + 'tenderingEnabled': false, + 'bundleEnabled': false, + 'dataSpaceEnabled': false, + 'launchValidationEnabled': false, + 'tenderDevButtonsOpenCloseEnabled': false, + 'aiSearchEnabled': false + }, 'domeAbout': 'https://dome-marketplace.eu/about/', 'domeRegister': 'https://dome-marketplace.github.io/onboarding/', 'domePublish': 'https://knowledgebase.dome-marketplace.org/shelves/company-onboarding-process', diff --git a/package-lock.json b/package-lock.json index 4f2016fe..bef3eeb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@fortawesome/sharp-solid-svg-icons": "^6.4.2", "@ngx-translate/core": "^15.0.0", "@ngx-translate/http-loader": "^8.0.0", + "@superset-ui/embedded-sdk": "^0.4.0", "@types/tailwindcss": "^3.1.0", "currencies.json": "^1.0.2", "fast-average-color": "^9.4.0", @@ -4149,6 +4150,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@superset-ui/embedded-sdk": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@superset-ui/embedded-sdk/-/embedded-sdk-0.4.0.tgz", + "integrity": "sha512-k/PnzvxO0xfeaAO6DRWsC7nG9oH3ooyBQNTRi/EXrK4DIH4ufx6Wy3AD3qDkCjGRw3KMdavCdwAiu2u4TVQ7oA==", + "license": "Apache-2.0", + "dependencies": { + "@superset-ui/switchboard": "^0.20.3", + "jwt-decode": "^4.0.0" + } + }, + "node_modules/@superset-ui/switchboard": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/@superset-ui/switchboard/-/switchboard-0.20.3.tgz", + "integrity": "sha512-qEMXFwdRLfXug4gXXdBEGpFtBWZoxdZkCJLBVxj1IR8cQvSqjkWAQOzSSYYdcIeREWqi8iP+iK6apNV1ZQCKcA==", + "license": "Apache-2.0" + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", diff --git a/package.json b/package.json index e29d7b93..a49b2469 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@fortawesome/sharp-solid-svg-icons": "^6.4.2", "@ngx-translate/core": "^15.0.0", "@ngx-translate/http-loader": "^8.0.0", + "@superset-ui/embedded-sdk": "^0.4.0", "@types/tailwindcss": "^3.1.0", "currencies.json": "^1.0.2", "fast-average-color": "^9.4.0", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 35240d88..dcf09ce0 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -103,6 +103,11 @@ const routes: Routes = [ component: ProductOrdersComponent, canActivate: [AuthGuard], data: { roles: [] } }, + { + path: 'analytics', + loadComponent: () => import('./pages/analytics/analytics.component').then(c => c.AnalyticsComponent), + canActivate: [AuthGuard], data: { roles: [] } + }, { path: 'quote-list', component: QuoteListComponent, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d660990c..c9c05d74 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -29,6 +29,7 @@ import { CategoriesComponent } from './pages/admin/categories/categories.compone import { CreateCategoryComponent } from './pages/admin/categories/create-category/create-category.component'; import { UpdateCategoryComponent } from './pages/admin/categories/update-category/update-category.component'; import { EmailComponent } from './pages/admin/email/email.component'; +import { FeaturesConfigComponent } from './pages/admin/features-config/features-config.component'; import { DefaultCatalogComponent } from './pages/admin/default-catalog/default-catalog.component'; import { SearchFiltersConfigComponent } from './pages/admin/search-filters-config/search-filters-config.component'; import { VerificationComponent } from './pages/admin/verification/verification.component'; @@ -152,6 +153,7 @@ import { RequestValidationModalComponent } from './pages/seller-offerings/offeri ContactUsComponent, VerificationComponent, EmailComponent, + FeaturesConfigComponent, SearchFiltersConfigComponent, DefaultCatalogComponent, InventoryResourcesComponent, diff --git a/src/app/data/featuresConfig.ts b/src/app/data/featuresConfig.ts new file mode 100644 index 00000000..6716976a --- /dev/null +++ b/src/app/data/featuresConfig.ts @@ -0,0 +1,116 @@ +import { environment } from 'src/environments/environment'; + +export type FeatureFlagKey = + | 'searchEnabled' + | 'purchaseEnabled' + | 'quotesEnabled' + | 'tenderingEnabled' + | 'bundleEnabled' + | 'dataSpaceEnabled' + | 'launchValidationEnabled' + | 'tenderDevButtonsOpenCloseEnabled' + | 'aiSearchEnabled'; + +export type FeaturesConfig = Partial> & Record; + +export interface FeatureFlagDefinition { + key: FeatureFlagKey; + label: string; + description: string; +} + +export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ + { + key: 'quotesEnabled', + label: 'Quotes', + description: 'Enable quote request flows and quote pages.' + }, + { + key: 'purchaseEnabled', + label: 'Purchases', + description: 'Enable shopping cart and checkout actions.' + }, + { + key: 'searchEnabled', + label: 'Search', + description: 'Enable catalog search controls.' + }, + { + key: 'aiSearchEnabled', + label: 'AI search', + description: 'Enable AI-assisted catalog search.' + }, + { + key: 'tenderingEnabled', + label: 'Tendering', + description: 'Enable tendering features.' + }, + { + key: 'bundleEnabled', + label: 'Bundles', + description: 'Enable bundle configuration in seller forms.' + }, + { + key: 'dataSpaceEnabled', + label: 'Data space', + description: 'Enable data space fields in organization and offer forms.' + }, + { + key: 'launchValidationEnabled', + label: 'Launch validation', + description: 'Enable launch validation requests for offerings.' + }, + { + key: 'tenderDevButtonsOpenCloseEnabled', + label: 'Tender dev actions', + description: 'Enable tender open and close development controls.' + } +]; + +function isRecord(value: any): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function firstBoolean(...values: any[]): boolean | undefined { + return values.find(value => typeof value === 'boolean'); +} + +export function readFeaturesConfig(config: any): FeaturesConfig { + const source = isRecord(config) ? config : {}; + const features = isRecord(source['features']) ? source['features'] : {}; + const ai = isRecord(source['ai']) ? source['ai'] : {}; + + const result: FeaturesConfig = {}; + + for (const [key, value] of Object.entries(features)) { + if (typeof value === 'boolean') { + result[key] = value; + } + } + + result.searchEnabled = firstBoolean(features['searchEnabled'], source['searchEnabled']); + result.purchaseEnabled = firstBoolean(features['purchaseEnabled'], source['purchaseEnabled']); + result.quotesEnabled = firstBoolean(features['quotesEnabled'], features['quoteEnabled'], source['quotesEnabled'], source['quoteEnabled']); + result.tenderingEnabled = firstBoolean(features['tenderingEnabled'], source['tenderingEnabled']); + result.bundleEnabled = firstBoolean(features['bundleEnabled'], source['bundleEnabled']); + result.dataSpaceEnabled = firstBoolean(features['dataSpaceEnabled'], source['dataSpaceEnabled']); + result.launchValidationEnabled = firstBoolean(features['launchValidationEnabled'], source['launchValidationEnabled']); + result.tenderDevButtonsOpenCloseEnabled = firstBoolean(features['tenderDevButtonsOpenCloseEnabled'], source['tenderDevButtonsOpenCloseEnabled']); + result.aiSearchEnabled = firstBoolean(features['aiSearchEnabled'], features['aiEnabled'], ai['aiEnabled'], source['aiEnabled']); + + return result; +} + +export function applyRuntimeFeaturesConfig(config: any): void { + const features = readFeaturesConfig(config); + + environment.SEARCH_ENABLED = features.searchEnabled ?? environment.SEARCH_ENABLED; + environment.PURCHASE_ENABLED = features.purchaseEnabled ?? true; + environment.QUOTES_ENABLED = features.quotesEnabled ?? false; + environment.TENDER_ENABLED = features.tenderingEnabled ?? false; + environment.BUNDLE_ENABLED = features.bundleEnabled ?? environment.BUNDLE_ENABLED; + environment.DATA_SPACE_ENABLED = features.dataSpaceEnabled ?? false; + environment.LAUNCH_VALIDATION_ENABLED = features.launchValidationEnabled ?? false; + environment.TENDER_DEV_BUTTONS_OPEN_CLOSE_ENABLED = features.tenderDevButtonsOpenCloseEnabled ?? false; + environment.AI_SEARCH_ENABLED = features.aiSearchEnabled ?? false; +} diff --git a/src/app/pages/admin/admin.component.html b/src/app/pages/admin/admin.component.html index 241a4379..5df319cc 100644 --- a/src/app/pages/admin/admin.component.html +++ b/src/app/pages/admin/admin.component.html @@ -48,6 +48,9 @@

Search & filters +
  • + Features +
  • @@ -84,9 +87,12 @@

    {{ 'ADMIN._email' | translate }} - + @@ -113,6 +119,9 @@

    +

    Features

    +
    + + @if(loading){ +
    + + Loading... +
    + } @else { +
    +
    + @for(flagControl of flagsArray.controls; track i; let i = $index){ +
    +
    + @if(flagControl.get('known')?.value){ +

    {{ getDefinition(flagControl.get('key')?.value ?? '')?.label }}

    +

    {{ getDefinition(flagControl.get('key')?.value ?? '')?.description }}

    +

    {{ flagControl.get('key')?.value }}

    + } @else { + + + } +
    + +
    + + + @if(!flagControl.get('known')?.value){ + + } +
    +
    + } +
    +
    + + @if(showSuccess){ + + } + +
    + + +
    + } + + +@if(showError){ + +} diff --git a/src/app/pages/admin/features-config/features-config.component.ts b/src/app/pages/admin/features-config/features-config.component.ts new file mode 100644 index 00000000..a7f08191 --- /dev/null +++ b/src/app/pages/admin/features-config/features-config.component.ts @@ -0,0 +1,182 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { + applyRuntimeFeaturesConfig, + FEATURE_FLAG_DEFINITIONS, + FeatureFlagDefinition, + readFeaturesConfig +} from 'src/app/data/featuresConfig'; +import { environment } from 'src/environments/environment'; + +type FeatureFlagFormGroup = FormGroup<{ + key: FormControl; + enabled: FormControl; + known: FormControl; +}>; + +@Component({ + selector: 'features-config', + templateUrl: './features-config.component.html', + styleUrl: './features-config.component.css' +}) +export class FeaturesConfigComponent implements OnInit, OnDestroy { + readonly definitions = FEATURE_FLAG_DEFINITIONS; + loading = false; + saving = false; + showError = false; + showSuccess = false; + errorMessage = ''; + successMessage = ''; + private successTimeoutId: ReturnType | null = null; + + featuresForm = new FormGroup({ + flags: new FormArray([]) + }); + + constructor(private http: HttpClient) {} + + ngOnInit(): void { + void this.loadConfig(); + } + + ngOnDestroy(): void { + if (this.successTimeoutId) { + clearTimeout(this.successTimeoutId); + this.successTimeoutId = null; + } + } + + get flagsArray(): FormArray { + return this.featuresForm.get('flags') as FormArray; + } + + getDefinition(key: string): FeatureFlagDefinition | undefined { + return this.definitions.find(definition => definition.key === key); + } + + async loadConfig(): Promise { + this.loading = true; + this.showError = false; + + try { + await this.syncFromBackend(); + } catch (error: any) { + this.handleError(error, 'There was an error while loading features configuration.'); + } finally { + this.loading = false; + } + } + + async saveConfig(): Promise { + if (this.saving) { + return; + } + + this.showError = false; + this.showSuccess = false; + this.saving = true; + + try { + const payload = this.buildFeaturesPayload(); + const url = `${environment.BASE_URL}/config/features`; + await firstValueFrom(this.http.patch(url, payload)); + + applyRuntimeFeaturesConfig({ features: payload }); + await this.syncFromBackend(); + + this.successMessage = 'Features configuration saved successfully.'; + this.showSuccess = true; + this.successTimeoutId = setTimeout(() => { + this.showSuccess = false; + }, 3000); + } catch (error: any) { + this.handleError(error, 'There was an error while saving features configuration.'); + } finally { + this.saving = false; + } + } + + addFeatureFlag(): void { + this.flagsArray.push(this.createFeatureGroup('', false, false)); + } + + removeFeatureFlag(index: number): void { + const flag = this.flagsArray.at(index); + if (flag.get('known')?.value) { + return; + } + + this.flagsArray.removeAt(index); + } + + private async syncFromBackend(): Promise { + const url = `${environment.BASE_URL}/config`; + const config = await firstValueFrom(this.http.get(url)); + const features = readFeaturesConfig(config); + this.loadFeatureFlags(features); + } + + private loadFeatureFlags(features: Record): void { + while (this.flagsArray.length > 0) { + this.flagsArray.removeAt(0); + } + + for (const definition of this.definitions) { + this.flagsArray.push(this.createFeatureGroup(definition.key, features[definition.key] ?? false, true)); + } + + Object.entries(features) + .filter(([key, value]) => typeof value === 'boolean' && !this.getDefinition(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .forEach(([key, value]) => { + this.flagsArray.push(this.createFeatureGroup(key, Boolean(value), false)); + }); + } + + private createFeatureGroup(key: string, enabled: boolean, known: boolean): FeatureFlagFormGroup { + return new FormGroup({ + key: new FormControl(key, { nonNullable: true, validators: [Validators.required] }), + enabled: new FormControl(enabled, { nonNullable: true }), + known: new FormControl(known, { nonNullable: true }) + }); + } + + private buildFeaturesPayload(): Record { + const payload: Record = {}; + const seenKeys = new Set(); + + this.flagsArray.controls.forEach((flagControl, index) => { + const key = (flagControl.get('key')?.value ?? '').trim(); + const enabled = flagControl.get('enabled')?.value === true; + + if (!key) { + throw new Error(`Feature ${index + 1}: key is required.`); + } + if (seenKeys.has(key)) { + throw new Error(`Feature "${key}" is duplicated.`); + } + + seenKeys.add(key); + payload[key] = enabled; + }); + + return payload; + } + + private handleError(error: any, fallbackMessage: string): void { + if (error?.error?.error) { + this.errorMessage = `Error: ${error.error.error}`; + } else if (error?.message) { + this.errorMessage = error.message; + } else { + this.errorMessage = fallbackMessage; + } + + this.showError = true; + setTimeout(() => { + this.showError = false; + }, 3000); + } +} diff --git a/src/app/pages/analytics/analytics.component.css b/src/app/pages/analytics/analytics.component.css new file mode 100644 index 00000000..cfa3d88e --- /dev/null +++ b/src/app/pages/analytics/analytics.component.css @@ -0,0 +1,44 @@ +.analytics-embed-shell { + position: relative; + width: 100%; + min-height: calc(100vh - 300px); + height: 760px; + background: #fff; +} + +.analytics-embed-mount { + width: 100%; + height: 100%; +} + +:host ::ng-deep .analytics-embed-mount iframe { + display: block; + width: 100% !important; + height: 100% !important; + border: 0; +} + +.analytics-status, +.analytics-error { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: rgba(255, 255, 255, 0.86); + text-align: center; +} + +.analytics-error { + padding: 24px; +} + +@media (max-width: 768px) { + .analytics-embed-shell { + min-height: 620px; + height: calc(100vh - 260px); + } +} diff --git a/src/app/pages/analytics/analytics.component.html b/src/app/pages/analytics/analytics.component.html new file mode 100644 index 00000000..409eeb74 --- /dev/null +++ b/src/app/pages/analytics/analytics.component.html @@ -0,0 +1,90 @@ +
    +
    +
    +
    +
    + + + +
    +
    +

    Analytics Dashboard

    +

    Marketplace analytics and usage monitoring.

    +
    +
    +
    + +
    +
    + @for(tab of visibleTabs; track tab.key){ + + } +
    +
    + +
    +
    +
    +

    {{ selectedTabConfig.label }}

    +

    {{ selectedTabConfig.description }}

    +
    + @if(hasDashboardConfig){ + + Open Superset + + } +
    +
    + +
    + @if(!hasDashboardConfig){ +
    +
    + + + +
    +

    No dashboard configured

    +

    + Configure the Superset domain to embed the remote dashboard here. +

    +
    + } @else { +
    + @if(loading || statusMessage){ +
    + @if(loading){ +
    + } + {{ statusMessage }} +
    + } + + @if(errorMessage){ +
    +

    Dashboard unavailable

    +

    {{ errorMessage }}

    +
    + } + +
    +
    + } +
    +
    +
    diff --git a/src/app/pages/analytics/analytics.component.ts b/src/app/pages/analytics/analytics.component.ts new file mode 100644 index 00000000..418ef384 --- /dev/null +++ b/src/app/pages/analytics/analytics.component.ts @@ -0,0 +1,222 @@ +import { CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core'; +import { embedDashboard, type EmbeddedDashboard } from '@superset-ui/embedded-sdk'; +import { firstValueFrom } from 'rxjs'; +import { LocalStorageService } from 'src/app/services/local-storage.service'; +import { environment } from 'src/environments/environment'; + +type AnalyticsTabKey = 'businessInsights' | 'usageMonitor'; + +interface AnalyticsTab { + key: AnalyticsTabKey; + label: string; + description: string; + iconPath: string; + adminOnly?: boolean; +} + +@Component({ + selector: 'app-analytics', + standalone: true, + imports: [CommonModule], + templateUrl: './analytics.component.html', + styleUrl: './analytics.component.css' +}) +export class AnalyticsComponent implements AfterViewInit, OnDestroy { + @ViewChild('supersetMount') supersetMount?: ElementRef; + + readonly tabs: AnalyticsTab[] = [ + { + key: 'businessInsights', + label: 'Business Insights', + description: 'Marketplace business performance, engagement and commercial analytics.', + iconPath: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125C16.5 3.504 17.004 3 17.625 3h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z' + }, + { + key: 'usageMonitor', + label: 'Usage Monitor', + description: 'Platform usage monitoring for marketplace administrators.', + iconPath: 'M9 17v-6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6m4 0V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v10m14 0H5m14 0a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2', + adminOnly: true + } + ]; + + selectedTab: AnalyticsTabKey = 'businessInsights'; + loading = false; + errorMessage = ''; + statusMessage = ''; + + private embeddedDashboard: EmbeddedDashboard | null = null; + private embedSequence = 0; + + constructor( + private http: HttpClient, + private localStorage: LocalStorageService + ) {} + + ngAfterViewInit(): void { + void this.embedSelectedDashboard(); + } + + ngOnDestroy(): void { + this.unmountDashboard(); + } + + selectTab(tab: AnalyticsTabKey): void { + if (this.selectedTab === tab || !this.visibleTabs.some(visibleTab => visibleTab.key === tab)) { + return; + } + + this.selectedTab = tab; + void this.embedSelectedDashboard(); + } + + get selectedTabConfig(): AnalyticsTab { + return this.visibleTabs.find(tab => tab.key === this.selectedTab) ?? this.visibleTabs[0] ?? this.tabs[0]; + } + + get visibleTabs(): AnalyticsTab[] { + return this.tabs.filter(tab => !tab.adminOnly || this.isAdmin); + } + + get isAdmin(): boolean { + const loginInfo = this.localStorage.getObject('login_items') as any; + if (!loginInfo || JSON.stringify(loginInfo) === '{}') { + return false; + } + + const adminRole = environment.ADMIN_ROLE?.toLowerCase(); + const userRoles = (loginInfo.roles ?? []).map((role: any) => role.name?.toLowerCase()); + const loggedOrganization = (loginInfo.organizations ?? []).find((organization: any) => organization.id === loginInfo.logged_as); + const organizationRoles = (loggedOrganization?.roles ?? []).map((role: any) => role.name?.toLowerCase()); + + return userRoles.includes(adminRole) || organizationRoles.includes(adminRole); + } + + get supersetDomain(): string { + return environment.analyticsSupersetDomain ?? ''; + } + + get hasDashboardConfig(): boolean { + return Boolean(this.supersetDomain); + } + + getTabClass(tab: AnalyticsTabKey): string { + return this.selectedTab === tab + ? 'bg-[#1f4fbf] text-white shadow-sm' + : 'bg-transparent text-[#526179] hover:bg-[#EBF0F7] hover:text-[#1f4fbf]'; + } + + private async embedSelectedDashboard(): Promise { + const sequence = ++this.embedSequence; + const mountPoint = this.supersetMount?.nativeElement; + + this.unmountDashboard(); + this.errorMessage = ''; + + if (!mountPoint) { + return; + } + + mountPoint.innerHTML = ''; + + if (!this.supersetDomain) { + this.loading = false; + this.statusMessage = 'Dashboard configuration is missing.'; + return; + } + + this.loading = true; + this.statusMessage = 'Loading dashboard...'; + + try { + const guestToken = await this.fetchGuestToken(this.selectedTab); + + if (sequence !== this.embedSequence) { + return; + } + + const embeddedDashboard = await embedDashboard({ + id: guestToken.dashboardId, + supersetDomain: this.supersetDomain, + mountPoint, + fetchGuestToken: () => Promise.resolve(guestToken.token), + dashboardUiConfig: { + hideTitle: false, + hideTab: false, + hideChartControls: true, + filters: { + expanded: true, + visible: true + }, + urlParams: {} + }, + iframeSandboxExtras: [ + 'allow-top-navigation', + 'allow-popups-to-escape-sandbox' + ], + referrerPolicy: '*' as ReferrerPolicy + }); + + if (sequence !== this.embedSequence) { + embeddedDashboard.unmount(); + return; + } + + this.embeddedDashboard = embeddedDashboard; + this.statusMessage = ''; + } catch (error) { + console.error('Failed to embed Superset dashboard', error); + if (sequence === this.embedSequence) { + this.errorMessage = 'Unable to load dashboard.'; + this.statusMessage = ''; + } + } finally { + if (sequence === this.embedSequence) { + this.loading = false; + } + } + } + + private async fetchGuestToken(tab: AnalyticsTabKey): Promise<{ dashboardId: string; token: string }> { + const endpoint = this.resolveBackendUrl(environment.analyticsGuestTokenEndpoint); + const response = await firstValueFrom(this.http.post(endpoint, { tab })); + const dashboardId = response?.dashboardId ?? response?.dashboard_id ?? response?.id ?? ''; + const token = this.extractGuestToken(response); + + if (!dashboardId) { + throw new Error('Guest token endpoint did not return a dashboard id.'); + } + if (!token) { + throw new Error('Guest token endpoint did not return a token.'); + } + + return { dashboardId, token }; + } + + private extractGuestToken(response: any): string { + if (typeof response === 'string') { + return response; + } + + return response?.token + ?? response?.guestToken + ?? response?.guest_token + ?? ''; + } + + private resolveBackendUrl(endpoint: string): string { + if (/^https?:\/\//i.test(endpoint)) { + return endpoint; + } + + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + return `${environment.BASE_URL}${normalizedEndpoint}`; + } + + private unmountDashboard(): void { + this.embeddedDashboard?.unmount(); + this.embeddedDashboard = null; + } +} diff --git a/src/app/services/app-init.service.ts b/src/app/services/app-init.service.ts index 7c4ad8b1..6f95e25d 100644 --- a/src/app/services/app-init.service.ts +++ b/src/app/services/app-init.service.ts @@ -3,6 +3,7 @@ import { Injectable } from "@angular/core"; import * as moment from "moment"; import { Observer } from "rxjs"; import { applyRuntimeSearchFiltersConfig } from "src/app/data/availableFilters"; +import { applyRuntimeFeaturesConfig } from "src/app/data/featuresConfig"; import { environment } from "src/environments/environment"; @Injectable({ @@ -23,10 +24,8 @@ export class AppInitService { environment.MATOMO_TRACKER_URL = config.matomoUrl; environment.KNOWLEDGE_BASE_URL = config.knowledgeBaseUrl; environment.TICKETING_SYSTEM_URL = config.ticketingUrl; - environment.SEARCH_ENABLED = config.searchEnabled ?? environment.SEARCH_ENABLED; environment.DOME_TRUST_LINK = config.domeTrust; environment.DOME_ABOUT_LINK = config.domeAbout; - environment.PURCHASE_ENABLED = config.purchaseEnabled ?? true; environment.DOME_REGISTER_LINK = config.domeRegister; environment.DOME_CUSTOMER_REGISTER_LINK = config.domeRegisterCustomer; environment.DOME_PUBLISH_LINK = config.domePublish; @@ -40,20 +39,17 @@ export class AppInitService { environment.ORG_ADMIN_ROLE = config.roles?.orgAdmin; environment.CERTIFIER_ROLE = config.roles?.certifier; environment.quoteApi = config.quoteApi ?? 'http://localhost:8080/quoteManagement'; - environment.analytics = config.analytics ?? 'https://analytics.dome-marketplace-sbx.org/', - environment.feedbackCampaign = config.feedbackCampaign ?? false, - environment.feedbackCampaignExpiration = config.feedbackCampaign ?? moment().add(1, 'week').unix() + const analyticsConfig = this.getAnalyticsConfig(config); + environment.analytics = analyticsConfig.link; + environment.analyticsSupersetDomain = analyticsConfig.supersetDomain; + environment.feedbackCampaign = config.feedbackCampaign ?? false; + environment.feedbackCampaignExpiration = config.feedbackCampaign ?? moment().add(1, 'week').unix(); environment.providerThemeName = config.theme ?? 'default'; - environment.QUOTES_ENABLED = config.quotesEnabled ?? false - environment.TENDER_ENABLED = config.tenderingEnabled ?? false - environment.DATA_SPACE_ENABLED = config.dataSpaceEnabled ?? false - environment.LEAR_URL = config.learUrl ?? '' - environment.LAUNCH_VALIDATION_ENABLED = config.launchValidationEnabled ?? false; - environment.TENDER_DEV_BUTTONS_OPEN_CLOSE_ENABLED = config.tenderDevButtonsOpenCloseEnabled ?? false; - environment.AI_SEARCH_ENABLED = aiConfig.aiEnabled ?? config.aiEnabled ?? false; + environment.LEAR_URL = config.learUrl ?? ''; environment.AI_SEARCH_API_KEY = aiConfig.aiApiKey ?? config.aiApiKey ?? ''; environment.AI_SEARCH_API_URL = aiConfig.aiApiUrl ?? config.aiApiUrl ?? ''; environment.AI_SEARCH_PROFILE = aiConfig.aiSearchProfile ?? config.aiSearchProfile ?? ''; + applyRuntimeFeaturesConfig(config); applyRuntimeSearchFiltersConfig(config); resolve(config); }), @@ -65,4 +61,23 @@ export class AppInitService { this.http.get(`${environment.BASE_URL}/config`).subscribe(obs); }); } + + private getAnalyticsConfig(config: any): { + link: string; + supersetDomain: string; + } { + const defaultAnalyticsLink = 'https://analytics.dome-marketplace-sbx.org/'; + const analyticsConfig = config?.analytics && typeof config.analytics === 'object' + ? config.analytics + : {}; + return { + link: typeof config?.analytics === 'string' + ? config.analytics + : analyticsConfig.link ?? defaultAnalyticsLink, + supersetDomain: config?.analyticsSupersetDomain + ?? analyticsConfig.supersetDomain + ?? analyticsConfig.domain + ?? '' + }; + } } diff --git a/src/app/shared/header/header.component.html b/src/app/shared/header/header.component.html index a94a1ec8..2b2f2972 100644 --- a/src/app/shared/header/header.component.html +++ b/src/app/shared/header/header.component.html @@ -107,7 +107,7 @@ }
  • - + Analytics
  • @@ -355,7 +355,7 @@ } - + Analytics diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 2dec72fe..ad24fece 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -89,6 +89,8 @@ export const environment = { QUOTES_ENABLED: true, TENDER_ENABLED: true, analytics: '', + analyticsSupersetDomain: '', + analyticsGuestTokenEndpoint: '/analytics/guest-token', feedbackCampaign: false, feedbackCampaignExpiration: 0, SELLER_ROLE: 'Seller', diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index 719c200b..943c6a2c 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -88,6 +88,8 @@ export const environment = { QUOTES_ENABLED: true, TENDER_ENABLED: true, analytics: '', + analyticsSupersetDomain: '', + analyticsGuestTokenEndpoint: '/analytics/guest-token', feedbackCampaign: false, feedbackCampaignExpiration: 0, SELLER_ROLE: 'Seller', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 2ce40b49..a172ce61 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -88,6 +88,8 @@ export const environment = { QUOTES_ENABLED: true, TENDER_ENABLED: true, analytics: '', + analyticsSupersetDomain: '', + analyticsGuestTokenEndpoint: '/analytics/guest-token', feedbackCampaign: false, feedbackCampaignExpiration: 0, SELLER_ROLE: 'Seller', From 01c3152a65c0cc520ed387aa5989ab9edf6031fb Mon Sep 17 00:00:00 2001 From: Francisco de la Vega Date: Tue, 23 Jun 2026 15:45:08 +0200 Subject: [PATCH 2/6] Manage feature flags from the admin page --- cypress/support/constants.ts | 17 +++--- src/app/data/featuresConfig.ts | 53 ++++++------------- .../features-config.component.html | 25 ++------- .../features-config.component.ts | 34 +++--------- 4 files changed, 33 insertions(+), 96 deletions(-) diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index fba19de7..6d39222a 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -19,17 +19,12 @@ export const init_config = { 'matomoUrl': '', 'searchEnabled': true, 'analyticsSupersetDomain': 'https://analytics.dome-marketplace-sbx.org', - 'features': { - 'searchEnabled': true, - 'purchaseEnabled': false, - 'quotesEnabled': false, - 'tenderingEnabled': false, - 'bundleEnabled': false, - 'dataSpaceEnabled': false, - 'launchValidationEnabled': false, - 'tenderDevButtonsOpenCloseEnabled': false, - 'aiSearchEnabled': false - }, + 'quotesEnabled': false, + 'tenderingEnabled': false, + 'dataSpaceEnabled': false, + 'launchValidationEnabled': false, + 'tenderDevButtonsOpenCloseEnabled': false, + 'aiEnabled': false, 'domeAbout': 'https://dome-marketplace.eu/about/', 'domeRegister': 'https://dome-marketplace.github.io/onboarding/', 'domePublish': 'https://knowledgebase.dome-marketplace.org/shelves/company-onboarding-process', diff --git a/src/app/data/featuresConfig.ts b/src/app/data/featuresConfig.ts index 6716976a..4ecb89d3 100644 --- a/src/app/data/featuresConfig.ts +++ b/src/app/data/featuresConfig.ts @@ -1,17 +1,17 @@ import { environment } from 'src/environments/environment'; export type FeatureFlagKey = - | 'searchEnabled' | 'purchaseEnabled' | 'quotesEnabled' | 'tenderingEnabled' - | 'bundleEnabled' | 'dataSpaceEnabled' | 'launchValidationEnabled' | 'tenderDevButtonsOpenCloseEnabled' - | 'aiSearchEnabled'; + | 'aiEnabled'; -export type FeaturesConfig = Partial> & Record; +export type RuntimeFeatureFlagKey = FeatureFlagKey | 'searchEnabled'; + +export type FeaturesConfig = Partial> & Record; export interface FeatureFlagDefinition { key: FeatureFlagKey; @@ -31,12 +31,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ description: 'Enable shopping cart and checkout actions.' }, { - key: 'searchEnabled', - label: 'Search', - description: 'Enable catalog search controls.' - }, - { - key: 'aiSearchEnabled', + key: 'aiEnabled', label: 'AI search', description: 'Enable AI-assisted catalog search.' }, @@ -45,11 +40,6 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ label: 'Tendering', description: 'Enable tendering features.' }, - { - key: 'bundleEnabled', - label: 'Bundles', - description: 'Enable bundle configuration in seller forms.' - }, { key: 'dataSpaceEnabled', label: 'Data space', @@ -71,32 +61,24 @@ function isRecord(value: any): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } -function firstBoolean(...values: any[]): boolean | undefined { - return values.find(value => typeof value === 'boolean'); +function readBoolean(source: Record, key: RuntimeFeatureFlagKey): boolean | undefined { + const value = source[key]; + return typeof value === 'boolean' ? value : undefined; } export function readFeaturesConfig(config: any): FeaturesConfig { const source = isRecord(config) ? config : {}; - const features = isRecord(source['features']) ? source['features'] : {}; - const ai = isRecord(source['ai']) ? source['ai'] : {}; const result: FeaturesConfig = {}; - for (const [key, value] of Object.entries(features)) { - if (typeof value === 'boolean') { - result[key] = value; - } - } - - result.searchEnabled = firstBoolean(features['searchEnabled'], source['searchEnabled']); - result.purchaseEnabled = firstBoolean(features['purchaseEnabled'], source['purchaseEnabled']); - result.quotesEnabled = firstBoolean(features['quotesEnabled'], features['quoteEnabled'], source['quotesEnabled'], source['quoteEnabled']); - result.tenderingEnabled = firstBoolean(features['tenderingEnabled'], source['tenderingEnabled']); - result.bundleEnabled = firstBoolean(features['bundleEnabled'], source['bundleEnabled']); - result.dataSpaceEnabled = firstBoolean(features['dataSpaceEnabled'], source['dataSpaceEnabled']); - result.launchValidationEnabled = firstBoolean(features['launchValidationEnabled'], source['launchValidationEnabled']); - result.tenderDevButtonsOpenCloseEnabled = firstBoolean(features['tenderDevButtonsOpenCloseEnabled'], source['tenderDevButtonsOpenCloseEnabled']); - result.aiSearchEnabled = firstBoolean(features['aiSearchEnabled'], features['aiEnabled'], ai['aiEnabled'], source['aiEnabled']); + result.searchEnabled = readBoolean(source, 'searchEnabled'); + result.purchaseEnabled = readBoolean(source, 'purchaseEnabled'); + result.quotesEnabled = readBoolean(source, 'quotesEnabled'); + result.tenderingEnabled = readBoolean(source, 'tenderingEnabled'); + result.dataSpaceEnabled = readBoolean(source, 'dataSpaceEnabled'); + result.launchValidationEnabled = readBoolean(source, 'launchValidationEnabled'); + result.tenderDevButtonsOpenCloseEnabled = readBoolean(source, 'tenderDevButtonsOpenCloseEnabled'); + result.aiEnabled = readBoolean(source, 'aiEnabled'); return result; } @@ -108,9 +90,8 @@ export function applyRuntimeFeaturesConfig(config: any): void { environment.PURCHASE_ENABLED = features.purchaseEnabled ?? true; environment.QUOTES_ENABLED = features.quotesEnabled ?? false; environment.TENDER_ENABLED = features.tenderingEnabled ?? false; - environment.BUNDLE_ENABLED = features.bundleEnabled ?? environment.BUNDLE_ENABLED; environment.DATA_SPACE_ENABLED = features.dataSpaceEnabled ?? false; environment.LAUNCH_VALIDATION_ENABLED = features.launchValidationEnabled ?? false; environment.TENDER_DEV_BUTTONS_OPEN_CLOSE_ENABLED = features.tenderDevButtonsOpenCloseEnabled ?? false; - environment.AI_SEARCH_ENABLED = features.aiSearchEnabled ?? false; + environment.AI_SEARCH_ENABLED = features.aiEnabled ?? false; } diff --git a/src/app/pages/admin/features-config/features-config.component.html b/src/app/pages/admin/features-config/features-config.component.html index 9fc58aa8..bc6203d1 100644 --- a/src/app/pages/admin/features-config/features-config.component.html +++ b/src/app/pages/admin/features-config/features-config.component.html @@ -16,15 +16,9 @@

    Features
    - @if(flagControl.get('known')?.value){ -

    {{ getDefinition(flagControl.get('key')?.value ?? '')?.label }}

    -

    {{ getDefinition(flagControl.get('key')?.value ?? '')?.description }}

    -

    {{ flagControl.get('key')?.value }}

    - } @else { - - - } +

    {{ getDefinition(flagControl.get('key')?.value ?? '')?.label }}

    +

    {{ getDefinition(flagControl.get('key')?.value ?? '')?.description }}

    +

    {{ flagControl.get('key')?.value }}

    @@ -32,13 +26,6 @@

    Features
    - - @if(!flagControl.get('known')?.value){ - - }

    } @@ -51,11 +38,7 @@

    Features } -
    - +
    + @@ -116,6 +122,9 @@

    +

    Analytics

    +
    + + @if(loading){ +
    + + Loading... +
    + } @else { +
    +
    +
    +
    +

    Analytics enabled

    +

    analyticsEnabled

    +
    + +
    + +
    + + +
    +
    + +
    +

    Dashboard IDs

    +
    + @for(section of dashboardSections; track section.key){ +
    + + +
    + } +
    +
    + +
    +

    Superset service account

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + @if(passwordConfigured !== null){ +

    passwordConfigured: {{ passwordConfigured }}

    + } +
    +
    + +
    +

    RLS configuration

    + @for(section of dashboardSections; track section.key){ +
    +
    +

    {{ section.label }}

    + +
    + +
    + @for(ruleControl of rlsArray(section.key).controls; track ruleIndex; let ruleIndex = $index){ +
    +
    +

    Rule {{ ruleIndex + 1 }}

    + +
    + + + + + + +
    + } +
    +
    + } +
    +
    +
    + + @if(showSuccess){ + + } + +
    + +
    + } +
    + +@if(showError){ + +} diff --git a/src/app/pages/admin/analytics-config/analytics-config.component.ts b/src/app/pages/admin/analytics-config/analytics-config.component.ts new file mode 100644 index 00000000..707dc421 --- /dev/null +++ b/src/app/pages/admin/analytics-config/analytics-config.component.ts @@ -0,0 +1,360 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +type AnalyticsDashboardKey = 'businessInsightsNonLear' | 'businessInsightsLear' | 'usageMonitor'; + +interface RlsRuleConfig { + datasets: number[]; + clauseTemplate: string; +} + +interface AnalyticsConfigPayload { + analyticsEnabled: boolean; + analyticsSupersetDomain: string; + analyticsDashboards: Record; + analyticsSuperset: { + url: string; + username: string; + password?: string; + provider: string; + rls: Record; + }; +} + +@Component({ + selector: 'analytics-config', + templateUrl: './analytics-config.component.html', + styleUrl: './analytics-config.component.css' +}) +export class AnalyticsConfigComponent implements OnInit, OnDestroy { + readonly dashboardSections: Array<{ key: AnalyticsDashboardKey; label: string }> = [ + { key: 'businessInsightsNonLear', label: 'Business Insights Non-LEAR' }, + { key: 'businessInsightsLear', label: 'Business Insights LEAR' }, + { key: 'usageMonitor', label: 'Usage Monitor' } + ]; + + loading = false; + saving = false; + showError = false; + showSuccess = false; + errorMessage = ''; + successMessage = ''; + passwordConfigured: boolean | null = null; + private successTimeoutId: ReturnType | null = null; + + analyticsForm = new FormGroup({ + analyticsEnabled: new FormControl(false, { nonNullable: true }), + analyticsSupersetDomain: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }), + analyticsDashboards: new FormGroup({ + businessInsightsNonLear: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }), + businessInsightsLear: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }), + usageMonitor: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }) + }), + analyticsSuperset: new FormGroup({ + url: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }), + username: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }), + password: new FormControl('', { nonNullable: true }), + provider: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }), + rls: new FormGroup({ + businessInsightsNonLear: new FormArray([]), + businessInsightsLear: new FormArray([]), + usageMonitor: new FormArray([]) + }) + }) + }); + + constructor(private http: HttpClient) {} + + ngOnInit(): void { + this.initializeEmptyRls(); + void this.loadConfig(); + } + + ngOnDestroy(): void { + if (this.successTimeoutId) { + clearTimeout(this.successTimeoutId); + this.successTimeoutId = null; + } + } + + async loadConfig(): Promise { + this.loading = true; + this.showError = false; + + try { + const config = await firstValueFrom(this.http.get(`${environment.BASE_URL}/config/analytics`)); + this.loadAnalyticsConfig(config); + } catch (error: any) { + this.handleError(error, 'There was an error while loading analytics configuration.'); + } finally { + this.loading = false; + } + } + + async saveConfig(): Promise { + if (this.saving) { + return; + } + + this.showError = false; + this.showSuccess = false; + this.saving = true; + + try { + const payload = this.buildPayload(); + const response = await firstValueFrom(this.http.patch(`${environment.BASE_URL}/config/analytics`, payload)); + + environment.analyticsSupersetDomain = response?.analyticsSupersetDomain ?? payload.analyticsSupersetDomain; + environment.analyticsEnabled = response?.analyticsEnabled ?? payload.analyticsEnabled; + this.passwordConfigured = response?.analyticsSuperset?.passwordConfigured ?? null; + this.analyticsForm.get('analyticsSuperset.password')?.reset(''); + this.loadAnalyticsConfig(response); + + this.successMessage = 'Analytics configuration saved successfully.'; + this.showSuccess = true; + this.successTimeoutId = setTimeout(() => { + this.showSuccess = false; + }, 3000); + } catch (error: any) { + this.handleError(error, 'There was an error while saving analytics configuration.'); + } finally { + this.saving = false; + } + } + + getDashboardControl(key: AnalyticsDashboardKey): FormControl { + return this.analyticsForm.get(`analyticsDashboards.${key}`) as FormControl; + } + + rlsArray(key: AnalyticsDashboardKey): FormArray { + return this.analyticsForm.get(`analyticsSuperset.rls.${key}`) as FormArray; + } + + addRlsRule(key: AnalyticsDashboardKey): void { + this.rlsArray(key).push(this.createRlsRuleGroup()); + } + + removeRlsRule(key: AnalyticsDashboardKey, index: number): void { + this.rlsArray(key).removeAt(index); + } + + private initializeEmptyRls(): void { + for (const section of this.dashboardSections) { + const rules = this.rlsArray(section.key); + while (rules.length > 0) { + rules.removeAt(0); + } + + rules.push(this.createRlsRuleGroup()); + } + } + + private loadAnalyticsConfig(config: any): void { + const analyticsSupersetDomain = typeof config?.analyticsSupersetDomain === 'string' + ? config.analyticsSupersetDomain.trim() + : ''; + const analyticsSuperset = config?.analyticsSuperset && typeof config.analyticsSuperset === 'object' + ? config.analyticsSuperset + : {}; + + this.passwordConfigured = typeof analyticsSuperset.passwordConfigured === 'boolean' + ? analyticsSuperset.passwordConfigured + : null; + + this.analyticsForm.patchValue({ + analyticsEnabled: typeof config?.analyticsEnabled === 'boolean' + ? config.analyticsEnabled + : false, + analyticsSupersetDomain, + analyticsDashboards: { + businessInsightsNonLear: this.readString(config?.analyticsDashboards?.businessInsightsNonLear), + businessInsightsLear: this.readString(config?.analyticsDashboards?.businessInsightsLear), + usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) + }, + analyticsSuperset: { + url: this.readString(analyticsSuperset.url), + username: this.readString(analyticsSuperset.username), + password: '', + provider: this.readString(analyticsSuperset.provider) + } + }); + + this.loadRlsFromConfig(analyticsSuperset.rls); + } + + private loadRlsFromConfig(rls: any): void { + const source = rls && typeof rls === 'object' ? rls : {}; + + for (const section of this.dashboardSections) { + const rules = this.rlsArray(section.key); + while (rules.length > 0) { + rules.removeAt(0); + } + + const sourceRules = Array.isArray(source[section.key]) ? source[section.key] : []; + if (sourceRules.length === 0) { + rules.push(this.createRlsRuleGroup()); + continue; + } + + for (const rule of sourceRules) { + rules.push(this.createRlsRuleGroup({ + datasets: Array.isArray(rule?.datasets) + ? rule.datasets.filter((dataset: any) => Number.isInteger(dataset)) + : [], + clauseTemplate: this.readString(rule?.clauseTemplate) + })); + } + } + } + + private createRlsRuleGroup(rule?: RlsRuleConfig): FormGroup { + return new FormGroup({ + datasets: new FormControl((rule?.datasets ?? []).join(', '), { + nonNullable: true, + validators: [Validators.required] + }), + clauseTemplate: new FormControl(rule?.clauseTemplate ?? '', { + nonNullable: true, + validators: [Validators.required] + }) + }); + } + + private buildPayload(): any { + const superset = this.analyticsForm.get('analyticsSuperset') as FormGroup; + const dashboards = this.analyticsForm.get('analyticsDashboards') as FormGroup; + const password = typeof superset.get('password')?.value === 'string' + ? superset.get('password')?.value.trim() + : ''; + + if (!password && this.passwordConfigured !== true) { + throw new Error('Superset password is required.'); + } + + const payload: AnalyticsConfigPayload = { + analyticsEnabled: this.analyticsForm.get('analyticsEnabled')?.value === true, + analyticsSupersetDomain: this.requireString(this.analyticsForm.get('analyticsSupersetDomain')?.value, 'Superset domain is required.'), + analyticsDashboards: { + businessInsightsNonLear: this.requireString(dashboards.get('businessInsightsNonLear')?.value, 'Business Insights Non-LEAR dashboard ID is required.'), + businessInsightsLear: this.requireString(dashboards.get('businessInsightsLear')?.value, 'Business Insights LEAR dashboard ID is required.'), + usageMonitor: this.requireString(dashboards.get('usageMonitor')?.value, 'Usage Monitor dashboard ID is required.') + }, + analyticsSuperset: { + url: this.requireString(superset.get('url')?.value, 'Superset URL is required.'), + username: this.requireString(superset.get('username')?.value, 'Superset username is required.'), + provider: this.requireString(superset.get('provider')?.value, 'Superset provider is required.'), + rls: { + businessInsightsNonLear: this.buildRlsRules('businessInsightsNonLear'), + businessInsightsLear: this.buildRlsRules('businessInsightsLear'), + usageMonitor: this.buildRlsRules('usageMonitor') + } + } + }; + + if (password) { + payload.analyticsSuperset.password = password; + } + + return payload; + } + + private buildRlsRules(key: AnalyticsDashboardKey): RlsRuleConfig[] { + const rules = this.rlsArray(key).controls.map((ruleControl, index) => { + const datasetsText = ruleControl.get('datasets')?.value; + const clauseTemplate = this.requireString( + ruleControl.get('clauseTemplate')?.value, + `${this.getDashboardLabel(key)} rule ${index + 1}: clause template is required.` + ); + + return { + datasets: this.parseDatasets(datasetsText, `${this.getDashboardLabel(key)} rule ${index + 1}`), + clauseTemplate + }; + }); + + if (rules.length === 0) { + throw new Error(`${this.getDashboardLabel(key)} must include at least one RLS rule.`); + } + + return rules; + } + + private parseDatasets(value: any, label: string): number[] { + const tokens = String(value ?? '') + .split(',') + .map(token => token.trim()) + .filter(token => token !== ''); + + if (tokens.length === 0) { + throw new Error(`${label}: provide at least one dataset ID.`); + } + + return tokens.map(token => { + const parsed = Number(token); + if (!Number.isInteger(parsed)) { + throw new Error(`${label}: dataset IDs must be comma-separated integers.`); + } + return parsed; + }); + } + + private requireString(value: any, message: string): string { + const normalized = typeof value === 'string' ? value.trim() : ''; + if (!normalized) { + throw new Error(message); + } + + return normalized; + } + + private readString(value: any): string { + return typeof value === 'string' ? value : ''; + } + + private getDashboardLabel(key: AnalyticsDashboardKey): string { + return this.dashboardSections.find(section => section.key === key)?.label ?? key; + } + + private handleError(error: any, fallbackMessage: string): void { + if (error?.error?.error) { + const details = error.error.details + ? ` ${typeof error.error.details === 'string' ? error.error.details : JSON.stringify(error.error.details)}` + : ''; + this.errorMessage = `Error: ${error.error.error}${details}`; + } else if (error?.message) { + this.errorMessage = error.message; + } else { + this.errorMessage = fallbackMessage; + } + + this.showError = true; + setTimeout(() => { + this.showError = false; + }, 3000); + } +} diff --git a/src/app/pages/analytics/analytics.component.ts b/src/app/pages/analytics/analytics.component.ts index 418ef384..248d6130 100644 --- a/src/app/pages/analytics/analytics.component.ts +++ b/src/app/pages/analytics/analytics.component.ts @@ -98,8 +98,12 @@ export class AnalyticsComponent implements AfterViewInit, OnDestroy { return environment.analyticsSupersetDomain ?? ''; } + get analyticsEnabled(): boolean { + return environment.analyticsEnabled; + } + get hasDashboardConfig(): boolean { - return Boolean(this.supersetDomain); + return this.analyticsEnabled && Boolean(this.supersetDomain); } getTabClass(tab: AnalyticsTabKey): string { @@ -121,6 +125,12 @@ export class AnalyticsComponent implements AfterViewInit, OnDestroy { mountPoint.innerHTML = ''; + if (!this.analyticsEnabled) { + this.loading = false; + this.statusMessage = 'Analytics is disabled.'; + return; + } + if (!this.supersetDomain) { this.loading = false; this.statusMessage = 'Dashboard configuration is missing.'; diff --git a/src/app/services/app-init.service.ts b/src/app/services/app-init.service.ts index 6f95e25d..f98da298 100644 --- a/src/app/services/app-init.service.ts +++ b/src/app/services/app-init.service.ts @@ -40,6 +40,7 @@ export class AppInitService { environment.CERTIFIER_ROLE = config.roles?.certifier; environment.quoteApi = config.quoteApi ?? 'http://localhost:8080/quoteManagement'; const analyticsConfig = this.getAnalyticsConfig(config); + environment.analyticsEnabled = config.analyticsEnabled ?? false; environment.analytics = analyticsConfig.link; environment.analyticsSupersetDomain = analyticsConfig.supersetDomain; environment.feedbackCampaign = config.feedbackCampaign ?? false; diff --git a/src/app/shared/header/header.component.html b/src/app/shared/header/header.component.html index 2b2f2972..473e9507 100644 --- a/src/app/shared/header/header.component.html +++ b/src/app/shared/header/header.component.html @@ -106,11 +106,13 @@ } + @if (analyticsEnabled) {
  • Analytics
  • + } @if (roles.includes(sellerRole)) {
  • @@ -355,9 +357,11 @@ } + @if (analyticsEnabled) { Analytics + } @if (roles.includes(sellerRole)) {
  • } @else { +
    + + +
    + + +
    +
    +
    diff --git a/src/app/pages/admin/analytics-config/analytics-config.component.ts b/src/app/pages/admin/analytics-config/analytics-config.component.ts index 707dc421..1849eaed 100644 --- a/src/app/pages/admin/analytics-config/analytics-config.component.ts +++ b/src/app/pages/admin/analytics-config/analytics-config.component.ts @@ -42,6 +42,7 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { showSuccess = false; errorMessage = ''; successMessage = ''; + providedJson = ''; passwordConfigured: boolean | null = null; private successTimeoutId: ReturnType | null = null; @@ -106,8 +107,7 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { this.showError = false; try { - const config = await firstValueFrom(this.http.get(`${environment.BASE_URL}/config/analytics`)); - this.loadAnalyticsConfig(config); + await this.syncFromBackend(); } catch (error: any) { this.handleError(error, 'There was an error while loading analytics configuration.'); } finally { @@ -126,13 +126,8 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { try { const payload = this.buildPayload(); - const response = await firstValueFrom(this.http.patch(`${environment.BASE_URL}/config/analytics`, payload)); - - environment.analyticsSupersetDomain = response?.analyticsSupersetDomain ?? payload.analyticsSupersetDomain; - environment.analyticsEnabled = response?.analyticsEnabled ?? payload.analyticsEnabled; - this.passwordConfigured = response?.analyticsSuperset?.passwordConfigured ?? null; - this.analyticsForm.get('analyticsSuperset.password')?.reset(''); - this.loadAnalyticsConfig(response); + await this.saveAnalyticsPayload(payload); + await this.syncFromBackend(); this.successMessage = 'Analytics configuration saved successfully.'; this.showSuccess = true; @@ -146,6 +141,50 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { } } + async saveProvidedJson(): Promise { + if (this.saving) { + return; + } + + this.showError = false; + this.showSuccess = false; + this.saving = true; + + try { + const parsed = this.parseProvidedJson(this.providedJson); + const payload = this.normalizeProvidedAnalyticsForSave(parsed); + await this.saveAnalyticsPayload(payload); + await this.syncFromBackend(); + + this.successMessage = 'Analytics JSON saved successfully.'; + this.showSuccess = true; + this.successTimeoutId = setTimeout(() => { + this.showSuccess = false; + }, 3000); + } catch (error: any) { + this.handleError(error, 'There was an error while saving provided JSON.'); + } finally { + this.saving = false; + } + } + + loadProvidedJsonIntoForm(): void { + try { + const parsed = this.parseProvidedJson(this.providedJson); + const normalized = this.normalizeProvidedAnalyticsForSave(parsed); + this.loadAnalyticsConfig(normalized); + if (normalized.analyticsSuperset.password) { + this.analyticsForm.get('analyticsSuperset.password')?.setValue(normalized.analyticsSuperset.password); + } + if (typeof parsed?.analyticsSuperset?.passwordConfigured === 'boolean') { + this.passwordConfigured = parsed.analyticsSuperset.passwordConfigured; + } + this.providedJson = JSON.stringify(this.normalizeAnalyticsForJson(normalized), null, 2); + } catch (error: any) { + this.handleError(error, 'The provided JSON could not be loaded into the form.'); + } + } + getDashboardControl(key: AnalyticsDashboardKey): FormControl { return this.analyticsForm.get(`analyticsDashboards.${key}`) as FormControl; } @@ -173,6 +212,19 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { } } + private async syncFromBackend(): Promise { + const config = await firstValueFrom(this.http.get(`${environment.BASE_URL}/config/analytics`)); + this.loadAnalyticsConfig(config); + this.providedJson = JSON.stringify(this.normalizeAnalyticsForJson(config), null, 2); + } + + private async saveAnalyticsPayload(payload: AnalyticsConfigPayload): Promise { + const response = await firstValueFrom(this.http.patch(`${environment.BASE_URL}/config/analytics`, payload)); + + environment.analyticsSupersetDomain = response?.analyticsSupersetDomain ?? payload.analyticsSupersetDomain; + environment.analyticsEnabled = response?.analyticsEnabled ?? payload.analyticsEnabled; + } + private loadAnalyticsConfig(config: any): void { const analyticsSupersetDomain = typeof config?.analyticsSupersetDomain === 'string' ? config.analyticsSupersetDomain.trim() @@ -283,6 +335,109 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { return payload; } + private normalizeProvidedAnalyticsForSave(parsed: any): AnalyticsConfigPayload { + const superset = parsed?.analyticsSuperset && typeof parsed.analyticsSuperset === 'object' + ? parsed.analyticsSuperset + : {}; + const password = typeof superset.password === 'string' + ? superset.password.trim() + : ''; + + if (!password && this.passwordConfigured !== true) { + throw new Error('Superset password is required.'); + } + + const payload: AnalyticsConfigPayload = { + analyticsEnabled: parsed?.analyticsEnabled === true, + analyticsSupersetDomain: this.requireString(parsed?.analyticsSupersetDomain, 'Superset domain is required.'), + analyticsDashboards: { + businessInsightsNonLear: this.requireString(parsed?.analyticsDashboards?.businessInsightsNonLear, 'Business Insights Non-LEAR dashboard ID is required.'), + businessInsightsLear: this.requireString(parsed?.analyticsDashboards?.businessInsightsLear, 'Business Insights LEAR dashboard ID is required.'), + usageMonitor: this.requireString(parsed?.analyticsDashboards?.usageMonitor, 'Usage Monitor dashboard ID is required.') + }, + analyticsSuperset: { + url: this.requireString(superset.url, 'Superset URL is required.'), + username: this.requireString(superset.username, 'Superset username is required.'), + provider: this.requireString(superset.provider, 'Superset provider is required.'), + rls: { + businessInsightsNonLear: this.normalizeRlsRules(superset?.rls?.businessInsightsNonLear, 'businessInsightsNonLear'), + businessInsightsLear: this.normalizeRlsRules(superset?.rls?.businessInsightsLear, 'businessInsightsLear'), + usageMonitor: this.normalizeRlsRules(superset?.rls?.usageMonitor, 'usageMonitor') + } + } + }; + + if (password) { + payload.analyticsSuperset.password = password; + } + + return payload; + } + + private normalizeAnalyticsForJson(config: any): any { + const analyticsSuperset = config?.analyticsSuperset && typeof config.analyticsSuperset === 'object' + ? config.analyticsSuperset + : {}; + const normalized = { + analyticsEnabled: config?.analyticsEnabled === true, + analyticsSupersetDomain: this.readString(config?.analyticsSupersetDomain), + analyticsDashboards: { + businessInsightsNonLear: this.readString(config?.analyticsDashboards?.businessInsightsNonLear), + businessInsightsLear: this.readString(config?.analyticsDashboards?.businessInsightsLear), + usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) + }, + analyticsSuperset: { + url: this.readString(analyticsSuperset.url), + username: this.readString(analyticsSuperset.username), + provider: this.readString(analyticsSuperset.provider), + rls: { + businessInsightsNonLear: this.normalizeRlsRulesForJson(analyticsSuperset?.rls?.businessInsightsNonLear), + businessInsightsLear: this.normalizeRlsRulesForJson(analyticsSuperset?.rls?.businessInsightsLear), + usageMonitor: this.normalizeRlsRulesForJson(analyticsSuperset?.rls?.usageMonitor) + } + } + }; + + if (typeof analyticsSuperset.passwordConfigured === 'boolean') { + return { + ...normalized, + analyticsSuperset: { + ...normalized.analyticsSuperset, + passwordConfigured: analyticsSuperset.passwordConfigured + } + }; + } + + return normalized; + } + + private normalizeRlsRules(rawRules: any, key: AnalyticsDashboardKey): RlsRuleConfig[] { + if (!Array.isArray(rawRules) || rawRules.length === 0) { + throw new Error(`${this.getDashboardLabel(key)} must include at least one RLS rule.`); + } + + return rawRules.map((rule: any, index: number) => ({ + datasets: this.normalizeDatasets(rule?.datasets, `${this.getDashboardLabel(key)} rule ${index + 1}`), + clauseTemplate: this.requireString( + rule?.clauseTemplate, + `${this.getDashboardLabel(key)} rule ${index + 1}: clause template is required.` + ) + })); + } + + private normalizeRlsRulesForJson(rawRules: any): RlsRuleConfig[] { + if (!Array.isArray(rawRules)) { + return []; + } + + return rawRules.map((rule: any) => ({ + datasets: Array.isArray(rule?.datasets) + ? rule.datasets.filter((dataset: any) => Number.isInteger(dataset)) + : [], + clauseTemplate: this.readString(rule?.clauseTemplate) + })); + } + private buildRlsRules(key: AnalyticsDashboardKey): RlsRuleConfig[] { const rules = this.rlsArray(key).controls.map((ruleControl, index) => { const datasetsText = ruleControl.get('datasets')?.value; @@ -323,6 +478,36 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { }); } + private normalizeDatasets(value: any, label: string): number[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label}: provide at least one dataset ID.`); + } + + return value.map(dataset => { + if (!Number.isInteger(dataset)) { + throw new Error(`${label}: dataset IDs must be integers.`); + } + return dataset; + }); + } + + private parseProvidedJson(raw: string): any { + const text = raw.trim(); + if (!text) { + throw new Error('Please provide a JSON value.'); + } + + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Provided JSON must be an object.'); + } + return parsed; + } catch { + throw new Error('Provided JSON is invalid.'); + } + } + private requireString(value: any, message: string): string { const normalized = typeof value === 'string' ? value.trim() : ''; if (!normalized) { From cb0b3a8cfd0a49343e3b76ef31d0c94a83db4f94 Mon Sep 17 00:00:00 2001 From: Francisco de la Vega Date: Tue, 30 Jun 2026 19:17:06 +0200 Subject: [PATCH 5/6] Update analytics url attribute --- cypress/support/constants.ts | 2 +- .../analytics-config.component.html | 9 +---- .../analytics-config.component.ts | 40 ++++++++++--------- .../pages/analytics/analytics.component.html | 2 +- .../pages/analytics/analytics.component.ts | 2 +- src/app/services/app-init.service.ts | 31 +++++--------- src/environments/environment.development.ts | 1 - src/environments/environment.production.ts | 1 - src/environments/environment.ts | 1 - 9 files changed, 37 insertions(+), 52 deletions(-) diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index 49b1464d..492e5975 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -19,7 +19,7 @@ export const init_config = { 'matomoUrl': '', 'searchEnabled': true, 'analyticsEnabled': true, - 'analyticsSupersetDomain': 'https://analytics.dome-marketplace-sbx.org', + 'analytics': 'https://analytics.dome-marketplace-sbx.org', 'quotesEnabled': false, 'tenderingEnabled': false, 'dataSpaceEnabled': false, diff --git a/src/app/pages/admin/analytics-config/analytics-config.component.html b/src/app/pages/admin/analytics-config/analytics-config.component.html index 0f2284e7..6d637a6e 100644 --- a/src/app/pages/admin/analytics-config/analytics-config.component.html +++ b/src/app/pages/admin/analytics-config/analytics-config.component.html @@ -42,8 +42,8 @@

    Analytics
    - - Analytics URL +

    @@ -64,11 +64,6 @@

    Dashboard IDs

    Superset service account

    -
    - - -
    ; analyticsSuperset: { - url: string; username: string; password?: string; provider: string; @@ -48,7 +47,7 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { analyticsForm = new FormGroup({ analyticsEnabled: new FormControl(false, { nonNullable: true }), - analyticsSupersetDomain: new FormControl('', { + analytics: new FormControl('', { nonNullable: true, validators: [Validators.required] }), @@ -67,10 +66,6 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { }) }), analyticsSuperset: new FormGroup({ - url: new FormControl('', { - nonNullable: true, - validators: [Validators.required] - }), username: new FormControl('', { nonNullable: true, validators: [Validators.required] @@ -221,14 +216,11 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { private async saveAnalyticsPayload(payload: AnalyticsConfigPayload): Promise { const response = await firstValueFrom(this.http.patch(`${environment.BASE_URL}/config/analytics`, payload)); - environment.analyticsSupersetDomain = response?.analyticsSupersetDomain ?? payload.analyticsSupersetDomain; + environment.analytics = response?.analytics ?? payload.analytics; environment.analyticsEnabled = response?.analyticsEnabled ?? payload.analyticsEnabled; } private loadAnalyticsConfig(config: any): void { - const analyticsSupersetDomain = typeof config?.analyticsSupersetDomain === 'string' - ? config.analyticsSupersetDomain.trim() - : ''; const analyticsSuperset = config?.analyticsSuperset && typeof config.analyticsSuperset === 'object' ? config.analyticsSuperset : {}; @@ -241,14 +233,13 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { analyticsEnabled: typeof config?.analyticsEnabled === 'boolean' ? config.analyticsEnabled : false, - analyticsSupersetDomain, + analytics: this.readAnalyticsUrl(config), analyticsDashboards: { businessInsightsNonLear: this.readString(config?.analyticsDashboards?.businessInsightsNonLear), businessInsightsLear: this.readString(config?.analyticsDashboards?.businessInsightsLear), usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) }, analyticsSuperset: { - url: this.readString(analyticsSuperset.url), username: this.readString(analyticsSuperset.username), password: '', provider: this.readString(analyticsSuperset.provider) @@ -310,14 +301,13 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { const payload: AnalyticsConfigPayload = { analyticsEnabled: this.analyticsForm.get('analyticsEnabled')?.value === true, - analyticsSupersetDomain: this.requireString(this.analyticsForm.get('analyticsSupersetDomain')?.value, 'Superset domain is required.'), + analytics: this.requireString(this.analyticsForm.get('analytics')?.value, 'Analytics URL is required.'), analyticsDashboards: { businessInsightsNonLear: this.requireString(dashboards.get('businessInsightsNonLear')?.value, 'Business Insights Non-LEAR dashboard ID is required.'), businessInsightsLear: this.requireString(dashboards.get('businessInsightsLear')?.value, 'Business Insights LEAR dashboard ID is required.'), usageMonitor: this.requireString(dashboards.get('usageMonitor')?.value, 'Usage Monitor dashboard ID is required.') }, analyticsSuperset: { - url: this.requireString(superset.get('url')?.value, 'Superset URL is required.'), username: this.requireString(superset.get('username')?.value, 'Superset username is required.'), provider: this.requireString(superset.get('provider')?.value, 'Superset provider is required.'), rls: { @@ -349,14 +339,13 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { const payload: AnalyticsConfigPayload = { analyticsEnabled: parsed?.analyticsEnabled === true, - analyticsSupersetDomain: this.requireString(parsed?.analyticsSupersetDomain, 'Superset domain is required.'), + analytics: this.requireString(this.readAnalyticsUrl(parsed), 'Analytics URL is required.'), analyticsDashboards: { businessInsightsNonLear: this.requireString(parsed?.analyticsDashboards?.businessInsightsNonLear, 'Business Insights Non-LEAR dashboard ID is required.'), businessInsightsLear: this.requireString(parsed?.analyticsDashboards?.businessInsightsLear, 'Business Insights LEAR dashboard ID is required.'), usageMonitor: this.requireString(parsed?.analyticsDashboards?.usageMonitor, 'Usage Monitor dashboard ID is required.') }, analyticsSuperset: { - url: this.requireString(superset.url, 'Superset URL is required.'), username: this.requireString(superset.username, 'Superset username is required.'), provider: this.requireString(superset.provider, 'Superset provider is required.'), rls: { @@ -380,14 +369,13 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { : {}; const normalized = { analyticsEnabled: config?.analyticsEnabled === true, - analyticsSupersetDomain: this.readString(config?.analyticsSupersetDomain), + analytics: this.readAnalyticsUrl(config), analyticsDashboards: { businessInsightsNonLear: this.readString(config?.analyticsDashboards?.businessInsightsNonLear), businessInsightsLear: this.readString(config?.analyticsDashboards?.businessInsightsLear), usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) }, analyticsSuperset: { - url: this.readString(analyticsSuperset.url), username: this.readString(analyticsSuperset.username), provider: this.readString(analyticsSuperset.provider), rls: { @@ -521,6 +509,20 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { return typeof value === 'string' ? value : ''; } + private readAnalyticsUrl(config: any): string { + if (typeof config?.analytics === 'string') { + return config.analytics.trim(); + } + + if (config?.analytics && typeof config.analytics === 'object') { + return this.readString(config.analytics.link) + || this.readString(config.analytics.url) + || this.readString(config.analytics.domain); + } + + return ''; + } + private getDashboardLabel(key: AnalyticsDashboardKey): string { return this.dashboardSections.find(section => section.key === key)?.label ?? key; } diff --git a/src/app/pages/analytics/analytics.component.html b/src/app/pages/analytics/analytics.component.html index 409eeb74..629c86b4 100644 --- a/src/app/pages/analytics/analytics.component.html +++ b/src/app/pages/analytics/analytics.component.html @@ -61,7 +61,7 @@

    {{ selectedTabConfig.label }}

    No dashboard configured

    - Configure the Superset domain to embed the remote dashboard here. + Configure the analytics URL to embed the remote dashboard here.

    } @else { diff --git a/src/app/pages/analytics/analytics.component.ts b/src/app/pages/analytics/analytics.component.ts index 248d6130..a1dd8df4 100644 --- a/src/app/pages/analytics/analytics.component.ts +++ b/src/app/pages/analytics/analytics.component.ts @@ -95,7 +95,7 @@ export class AnalyticsComponent implements AfterViewInit, OnDestroy { } get supersetDomain(): string { - return environment.analyticsSupersetDomain ?? ''; + return environment.analytics ?? ''; } get analyticsEnabled(): boolean { diff --git a/src/app/services/app-init.service.ts b/src/app/services/app-init.service.ts index ca4fdb0b..faa8b874 100644 --- a/src/app/services/app-init.service.ts +++ b/src/app/services/app-init.service.ts @@ -39,10 +39,8 @@ export class AppInitService { environment.ORG_ADMIN_ROLE = config.roles?.orgAdmin; environment.CERTIFIER_ROLE = config.roles?.certifier; environment.quoteApi = config.quoteApi ?? 'http://localhost:8080/quoteManagement'; - const analyticsConfig = this.getAnalyticsConfig(config); environment.analyticsEnabled = config.analyticsEnabled ?? false; - environment.analytics = analyticsConfig.link; - environment.analyticsSupersetDomain = analyticsConfig.supersetDomain; + environment.analytics = this.getAnalyticsUrl(config); environment.feedbackCampaign = config.feedbackCampaign ?? false; environment.feedbackCampaignExpiration = config.feedbackCampaign ?? moment().add(1, 'week').unix(); environment.documentApi = config.documentApi ?? environment.documentApi; @@ -64,22 +62,15 @@ export class AppInitService { }); } - private getAnalyticsConfig(config: any): { - link: string; - supersetDomain: string; - } { - const defaultAnalyticsLink = 'https://analytics.dome-marketplace-sbx.org/'; - const analyticsConfig = config?.analytics && typeof config.analytics === 'object' - ? config.analytics - : {}; - return { - link: typeof config?.analytics === 'string' - ? config.analytics - : analyticsConfig.link ?? defaultAnalyticsLink, - supersetDomain: config?.analyticsSupersetDomain - ?? analyticsConfig.supersetDomain - ?? analyticsConfig.domain - ?? '' - }; + private getAnalyticsUrl(config: any): string { + if (typeof config?.analytics === 'string') { + return config.analytics; + } + + if (config?.analytics && typeof config.analytics === 'object') { + return config.analytics.link ?? config.analytics.url ?? config.analytics.domain ?? ''; + } + + return ''; } } diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 74c24f0d..2517b5ba 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -91,7 +91,6 @@ export const environment = { TENDER_ENABLED: true, analyticsEnabled: false, analytics: '', - analyticsSupersetDomain: '', analyticsGuestTokenEndpoint: '/analytics/guest-token', feedbackCampaign: false, feedbackCampaignExpiration: 0, diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index 844221bb..a3128761 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -90,7 +90,6 @@ export const environment = { TENDER_ENABLED: true, analyticsEnabled: false, analytics: '', - analyticsSupersetDomain: '', analyticsGuestTokenEndpoint: '/analytics/guest-token', feedbackCampaign: false, feedbackCampaignExpiration: 0, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index cabca98e..e14840f6 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -90,7 +90,6 @@ export const environment = { TENDER_ENABLED: true, analyticsEnabled: false, analytics: '', - analyticsSupersetDomain: '', analyticsGuestTokenEndpoint: '/analytics/guest-token', feedbackCampaign: false, feedbackCampaignExpiration: 0, From ccab073d41b6dbd845d10f8fcfc7b1a897166a3f Mon Sep 17 00:00:00 2001 From: Francisco de la Vega Date: Tue, 14 Jul 2026 11:51:10 +0200 Subject: [PATCH 6/6] Update analytics admin form to new config --- cypress/support/constants.ts | 8 + .../analytics-config.component.html | 62 +--- .../analytics-config.component.spec.ts | 109 +++++++ .../analytics-config.component.ts | 269 ++---------------- 4 files changed, 148 insertions(+), 300 deletions(-) create mode 100644 src/app/pages/admin/analytics-config/analytics-config.component.spec.ts diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index 492e5975..629422ed 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -20,6 +20,14 @@ export const init_config = { 'searchEnabled': true, 'analyticsEnabled': true, 'analytics': 'https://analytics.dome-marketplace-sbx.org', + 'analyticsDashboards': { + 'businessInsightsNonLear': 'business-insights-non-lear', + 'businessInsightsLear': 'business-insights-lear', + 'usageMonitor': 'usage-monitor' + }, + 'analyticsSuperset': { + 'guestTokenPath': '/api/v1/dome/guest_token/' + }, 'quotesEnabled': false, 'tenderingEnabled': false, 'dataSpaceEnabled': false, diff --git a/src/app/pages/admin/analytics-config/analytics-config.component.html b/src/app/pages/admin/analytics-config/analytics-config.component.html index 6d637a6e..ba2f03cf 100644 --- a/src/app/pages/admin/analytics-config/analytics-config.component.html +++ b/src/app/pages/admin/analytics-config/analytics-config.component.html @@ -62,63 +62,11 @@

    Dashboard IDs

    -

    Superset service account

    -
    -
    - - -
    -
    - - -
    -
    - - - @if(passwordConfigured !== null){ -

    passwordConfigured: {{ passwordConfigured }}

    - } -
    -
    - -
    -

    RLS configuration

    - @for(section of dashboardSections; track section.key){ -
    -
    -

    {{ section.label }}

    - -
    - -
    - @for(ruleControl of rlsArray(section.key).controls; track ruleIndex; let ruleIndex = $index){ -
    -
    -

    Rule {{ ruleIndex + 1 }}

    - -
    - - - - - - -
    - } -
    -
    - } +

    Superset guest token

    +
    + +
    diff --git a/src/app/pages/admin/analytics-config/analytics-config.component.spec.ts b/src/app/pages/admin/analytics-config/analytics-config.component.spec.ts new file mode 100644 index 00000000..447e1d41 --- /dev/null +++ b/src/app/pages/admin/analytics-config/analytics-config.component.spec.ts @@ -0,0 +1,109 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AnalyticsConfigComponent } from './analytics-config.component'; +import { environment } from 'src/environments/environment'; + +describe('AnalyticsConfigComponent', () => { + let component: AnalyticsConfigComponent; + let httpMock: HttpTestingController; + + const analyticsConfig = { + analytics: 'https://superset.example.com', + analyticsEnabled: true, + analyticsDashboards: { + businessInsightsNonLear: 'business-non-lear-dashboard', + businessInsightsLear: 'business-lear-dashboard', + usageMonitor: 'usage-monitor-dashboard' + }, + analyticsSuperset: { + guestTokenPath: '/api/v1/dome/guest_token/' + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule] + }); + + component = new AnalyticsConfigComponent(TestBed.inject(HttpClient)); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('renders GET /config/analytics using the new guest token config shape', async () => { + const promise = component.loadConfig(); + + const req = httpMock.expectOne(`${environment.BASE_URL}/config/analytics`); + expect(req.request.method).toBe('GET'); + req.flush(analyticsConfig); + + await promise; + + expect(component.analyticsForm.value).toEqual(analyticsConfig); + expect(JSON.parse(component.providedJson)).toEqual(analyticsConfig); + }); + + it('sends exactly the new analytics config shape when saving the form', async () => { + component.analyticsForm.setValue(analyticsConfig); + + const promise = component.saveConfig(); + + const patchReq = httpMock.expectOne(`${environment.BASE_URL}/config/analytics`); + expect(patchReq.request.method).toBe('PATCH'); + expect(patchReq.request.body).toEqual(analyticsConfig); + expect(patchReq.request.body.analyticsSuperset).toEqual({ + guestTokenPath: '/api/v1/dome/guest_token/' + }); + patchReq.flush(analyticsConfig); + await waitForPendingRequests(); + + const getReq = httpMock.expectOne(`${environment.BASE_URL}/config/analytics`); + expect(getReq.request.method).toBe('GET'); + getReq.flush(analyticsConfig); + + await promise; + }); + + it('drops obsolete Superset auth and RLS fields when saving provided JSON', async () => { + component.providedJson = JSON.stringify({ + ...analyticsConfig, + analyticsSuperset: { + ...analyticsConfig.analyticsSuperset, + username: 'admin', + password: 'secret', + provider: 'db', + passwordConfigured: true, + rls: { + businessInsightsNonLear: [ + { + datasets: [1], + clauseTemplate: 'party_id = {{ partyId }}' + } + ] + } + } + }); + + const promise = component.saveProvidedJson(); + + const patchReq = httpMock.expectOne(`${environment.BASE_URL}/config/analytics`); + expect(patchReq.request.method).toBe('PATCH'); + expect(patchReq.request.body).toEqual(analyticsConfig); + patchReq.flush(analyticsConfig); + await waitForPendingRequests(); + + const getReq = httpMock.expectOne(`${environment.BASE_URL}/config/analytics`); + expect(getReq.request.method).toBe('GET'); + getReq.flush(analyticsConfig); + + await promise; + }); +}); + +function waitForPendingRequests(): Promise { + return new Promise(resolve => setTimeout(resolve)); +} diff --git a/src/app/pages/admin/analytics-config/analytics-config.component.ts b/src/app/pages/admin/analytics-config/analytics-config.component.ts index c7fb5f1c..1faafe66 100644 --- a/src/app/pages/admin/analytics-config/analytics-config.component.ts +++ b/src/app/pages/admin/analytics-config/analytics-config.component.ts @@ -1,25 +1,18 @@ import { HttpClient } from '@angular/common/http'; import { Component, OnDestroy, OnInit } from '@angular/core'; -import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; import { environment } from 'src/environments/environment'; type AnalyticsDashboardKey = 'businessInsightsNonLear' | 'businessInsightsLear' | 'usageMonitor'; - -interface RlsRuleConfig { - datasets: number[]; - clauseTemplate: string; -} +const DEFAULT_GUEST_TOKEN_PATH = '/api/v1/dome/guest_token/'; interface AnalyticsConfigPayload { - analyticsEnabled: boolean; analytics: string; + analyticsEnabled: boolean; analyticsDashboards: Record; analyticsSuperset: { - username: string; - password?: string; - provider: string; - rls: Record; + guestTokenPath: string; }; } @@ -42,7 +35,6 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { errorMessage = ''; successMessage = ''; providedJson = ''; - passwordConfigured: boolean | null = null; private successTimeoutId: ReturnType | null = null; analyticsForm = new FormGroup({ @@ -66,19 +58,9 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { }) }), analyticsSuperset: new FormGroup({ - username: new FormControl('', { - nonNullable: true, - validators: [Validators.required] - }), - password: new FormControl('', { nonNullable: true }), - provider: new FormControl('', { + guestTokenPath: new FormControl(DEFAULT_GUEST_TOKEN_PATH, { nonNullable: true, validators: [Validators.required] - }), - rls: new FormGroup({ - businessInsightsNonLear: new FormArray([]), - businessInsightsLear: new FormArray([]), - usageMonitor: new FormArray([]) }) }) }); @@ -86,7 +68,6 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { constructor(private http: HttpClient) {} ngOnInit(): void { - this.initializeEmptyRls(); void this.loadConfig(); } @@ -168,12 +149,6 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { const parsed = this.parseProvidedJson(this.providedJson); const normalized = this.normalizeProvidedAnalyticsForSave(parsed); this.loadAnalyticsConfig(normalized); - if (normalized.analyticsSuperset.password) { - this.analyticsForm.get('analyticsSuperset.password')?.setValue(normalized.analyticsSuperset.password); - } - if (typeof parsed?.analyticsSuperset?.passwordConfigured === 'boolean') { - this.passwordConfigured = parsed.analyticsSuperset.passwordConfigured; - } this.providedJson = JSON.stringify(this.normalizeAnalyticsForJson(normalized), null, 2); } catch (error: any) { this.handleError(error, 'The provided JSON could not be loaded into the form.'); @@ -184,29 +159,6 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { return this.analyticsForm.get(`analyticsDashboards.${key}`) as FormControl; } - rlsArray(key: AnalyticsDashboardKey): FormArray { - return this.analyticsForm.get(`analyticsSuperset.rls.${key}`) as FormArray; - } - - addRlsRule(key: AnalyticsDashboardKey): void { - this.rlsArray(key).push(this.createRlsRuleGroup()); - } - - removeRlsRule(key: AnalyticsDashboardKey, index: number): void { - this.rlsArray(key).removeAt(index); - } - - private initializeEmptyRls(): void { - for (const section of this.dashboardSections) { - const rules = this.rlsArray(section.key); - while (rules.length > 0) { - rules.removeAt(0); - } - - rules.push(this.createRlsRuleGroup()); - } - } - private async syncFromBackend(): Promise { const config = await firstValueFrom(this.http.get(`${environment.BASE_URL}/config/analytics`)); this.loadAnalyticsConfig(config); @@ -221,14 +173,6 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { } private loadAnalyticsConfig(config: any): void { - const analyticsSuperset = config?.analyticsSuperset && typeof config.analyticsSuperset === 'object' - ? config.analyticsSuperset - : {}; - - this.passwordConfigured = typeof analyticsSuperset.passwordConfigured === 'boolean' - ? analyticsSuperset.passwordConfigured - : null; - this.analyticsForm.patchValue({ analyticsEnabled: typeof config?.analyticsEnabled === 'boolean' ? config.analyticsEnabled @@ -240,88 +184,28 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) }, analyticsSuperset: { - username: this.readString(analyticsSuperset.username), - password: '', - provider: this.readString(analyticsSuperset.provider) + guestTokenPath: this.readGuestTokenPath(config) } }); - - this.loadRlsFromConfig(analyticsSuperset.rls); } - private loadRlsFromConfig(rls: any): void { - const source = rls && typeof rls === 'object' ? rls : {}; - - for (const section of this.dashboardSections) { - const rules = this.rlsArray(section.key); - while (rules.length > 0) { - rules.removeAt(0); - } - - const sourceRules = Array.isArray(source[section.key]) ? source[section.key] : []; - if (sourceRules.length === 0) { - rules.push(this.createRlsRuleGroup()); - continue; - } - - for (const rule of sourceRules) { - rules.push(this.createRlsRuleGroup({ - datasets: Array.isArray(rule?.datasets) - ? rule.datasets.filter((dataset: any) => Number.isInteger(dataset)) - : [], - clauseTemplate: this.readString(rule?.clauseTemplate) - })); - } - } - } - - private createRlsRuleGroup(rule?: RlsRuleConfig): FormGroup { - return new FormGroup({ - datasets: new FormControl((rule?.datasets ?? []).join(', '), { - nonNullable: true, - validators: [Validators.required] - }), - clauseTemplate: new FormControl(rule?.clauseTemplate ?? '', { - nonNullable: true, - validators: [Validators.required] - }) - }); - } - - private buildPayload(): any { + private buildPayload(): AnalyticsConfigPayload { const superset = this.analyticsForm.get('analyticsSuperset') as FormGroup; const dashboards = this.analyticsForm.get('analyticsDashboards') as FormGroup; - const password = typeof superset.get('password')?.value === 'string' - ? superset.get('password')?.value.trim() - : ''; - - if (!password && this.passwordConfigured !== true) { - throw new Error('Superset password is required.'); - } const payload: AnalyticsConfigPayload = { - analyticsEnabled: this.analyticsForm.get('analyticsEnabled')?.value === true, analytics: this.requireString(this.analyticsForm.get('analytics')?.value, 'Analytics URL is required.'), + analyticsEnabled: this.requireBoolean(this.analyticsForm.get('analyticsEnabled')?.value, 'Analytics enabled must be a boolean.'), analyticsDashboards: { businessInsightsNonLear: this.requireString(dashboards.get('businessInsightsNonLear')?.value, 'Business Insights Non-LEAR dashboard ID is required.'), businessInsightsLear: this.requireString(dashboards.get('businessInsightsLear')?.value, 'Business Insights LEAR dashboard ID is required.'), usageMonitor: this.requireString(dashboards.get('usageMonitor')?.value, 'Usage Monitor dashboard ID is required.') }, analyticsSuperset: { - username: this.requireString(superset.get('username')?.value, 'Superset username is required.'), - provider: this.requireString(superset.get('provider')?.value, 'Superset provider is required.'), - rls: { - businessInsightsNonLear: this.buildRlsRules('businessInsightsNonLear'), - businessInsightsLear: this.buildRlsRules('businessInsightsLear'), - usageMonitor: this.buildRlsRules('usageMonitor') - } + guestTokenPath: this.requireString(superset.get('guestTokenPath')?.value, 'Guest token path is required.') } }; - if (password) { - payload.analyticsSuperset.password = password; - } - return payload; } @@ -329,37 +213,20 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { const superset = parsed?.analyticsSuperset && typeof parsed.analyticsSuperset === 'object' ? parsed.analyticsSuperset : {}; - const password = typeof superset.password === 'string' - ? superset.password.trim() - : ''; - - if (!password && this.passwordConfigured !== true) { - throw new Error('Superset password is required.'); - } const payload: AnalyticsConfigPayload = { - analyticsEnabled: parsed?.analyticsEnabled === true, analytics: this.requireString(this.readAnalyticsUrl(parsed), 'Analytics URL is required.'), + analyticsEnabled: this.requireBoolean(parsed?.analyticsEnabled, 'Analytics enabled must be a boolean.'), analyticsDashboards: { businessInsightsNonLear: this.requireString(parsed?.analyticsDashboards?.businessInsightsNonLear, 'Business Insights Non-LEAR dashboard ID is required.'), businessInsightsLear: this.requireString(parsed?.analyticsDashboards?.businessInsightsLear, 'Business Insights LEAR dashboard ID is required.'), usageMonitor: this.requireString(parsed?.analyticsDashboards?.usageMonitor, 'Usage Monitor dashboard ID is required.') }, analyticsSuperset: { - username: this.requireString(superset.username, 'Superset username is required.'), - provider: this.requireString(superset.provider, 'Superset provider is required.'), - rls: { - businessInsightsNonLear: this.normalizeRlsRules(superset?.rls?.businessInsightsNonLear, 'businessInsightsNonLear'), - businessInsightsLear: this.normalizeRlsRules(superset?.rls?.businessInsightsLear, 'businessInsightsLear'), - usageMonitor: this.normalizeRlsRules(superset?.rls?.usageMonitor, 'usageMonitor') - } + guestTokenPath: this.requireString(superset.guestTokenPath, 'Guest token path is required.') } }; - if (password) { - payload.analyticsSuperset.password = password; - } - return payload; } @@ -376,109 +243,13 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) }, analyticsSuperset: { - username: this.readString(analyticsSuperset.username), - provider: this.readString(analyticsSuperset.provider), - rls: { - businessInsightsNonLear: this.normalizeRlsRulesForJson(analyticsSuperset?.rls?.businessInsightsNonLear), - businessInsightsLear: this.normalizeRlsRulesForJson(analyticsSuperset?.rls?.businessInsightsLear), - usageMonitor: this.normalizeRlsRulesForJson(analyticsSuperset?.rls?.usageMonitor) - } + guestTokenPath: this.readString(analyticsSuperset.guestTokenPath) || DEFAULT_GUEST_TOKEN_PATH } }; - if (typeof analyticsSuperset.passwordConfigured === 'boolean') { - return { - ...normalized, - analyticsSuperset: { - ...normalized.analyticsSuperset, - passwordConfigured: analyticsSuperset.passwordConfigured - } - }; - } - return normalized; } - private normalizeRlsRules(rawRules: any, key: AnalyticsDashboardKey): RlsRuleConfig[] { - if (!Array.isArray(rawRules) || rawRules.length === 0) { - throw new Error(`${this.getDashboardLabel(key)} must include at least one RLS rule.`); - } - - return rawRules.map((rule: any, index: number) => ({ - datasets: this.normalizeDatasets(rule?.datasets, `${this.getDashboardLabel(key)} rule ${index + 1}`), - clauseTemplate: this.requireString( - rule?.clauseTemplate, - `${this.getDashboardLabel(key)} rule ${index + 1}: clause template is required.` - ) - })); - } - - private normalizeRlsRulesForJson(rawRules: any): RlsRuleConfig[] { - if (!Array.isArray(rawRules)) { - return []; - } - - return rawRules.map((rule: any) => ({ - datasets: Array.isArray(rule?.datasets) - ? rule.datasets.filter((dataset: any) => Number.isInteger(dataset)) - : [], - clauseTemplate: this.readString(rule?.clauseTemplate) - })); - } - - private buildRlsRules(key: AnalyticsDashboardKey): RlsRuleConfig[] { - const rules = this.rlsArray(key).controls.map((ruleControl, index) => { - const datasetsText = ruleControl.get('datasets')?.value; - const clauseTemplate = this.requireString( - ruleControl.get('clauseTemplate')?.value, - `${this.getDashboardLabel(key)} rule ${index + 1}: clause template is required.` - ); - - return { - datasets: this.parseDatasets(datasetsText, `${this.getDashboardLabel(key)} rule ${index + 1}`), - clauseTemplate - }; - }); - - if (rules.length === 0) { - throw new Error(`${this.getDashboardLabel(key)} must include at least one RLS rule.`); - } - - return rules; - } - - private parseDatasets(value: any, label: string): number[] { - const tokens = String(value ?? '') - .split(',') - .map(token => token.trim()) - .filter(token => token !== ''); - - if (tokens.length === 0) { - throw new Error(`${label}: provide at least one dataset ID.`); - } - - return tokens.map(token => { - const parsed = Number(token); - if (!Number.isInteger(parsed)) { - throw new Error(`${label}: dataset IDs must be comma-separated integers.`); - } - return parsed; - }); - } - - private normalizeDatasets(value: any, label: string): number[] { - if (!Array.isArray(value) || value.length === 0) { - throw new Error(`${label}: provide at least one dataset ID.`); - } - - return value.map(dataset => { - if (!Number.isInteger(dataset)) { - throw new Error(`${label}: dataset IDs must be integers.`); - } - return dataset; - }); - } - private parseProvidedJson(raw: string): any { const text = raw.trim(); if (!text) { @@ -505,6 +276,14 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { return normalized; } + private requireBoolean(value: any, message: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(message); + } + + return value; + } + private readString(value: any): string { return typeof value === 'string' ? value : ''; } @@ -523,8 +302,12 @@ export class AnalyticsConfigComponent implements OnInit, OnDestroy { return ''; } - private getDashboardLabel(key: AnalyticsDashboardKey): string { - return this.dashboardSections.find(section => section.key === key)?.label ?? key; + private readGuestTokenPath(config: any): string { + const analyticsSuperset = config?.analyticsSuperset && typeof config.analyticsSuperset === 'object' + ? config.analyticsSuperset + : {}; + + return this.readString(analyticsSuperset.guestTokenPath) || DEFAULT_GUEST_TOKEN_PATH; } private handleError(error: any, fallbackMessage: string): void {