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 d1004d65..17fd72bf 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -19,6 +19,22 @@ export const init_config = { 'matomoUrl': '', 'googleTagManagerId': '', '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, + '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/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 80dbe3b8..952108db 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -25,10 +25,12 @@ import { GalleryComponent } from "./offerings/gallery/gallery.component"; import { HowItWorksComponent } from "./offerings/how-it-works/how-it-works.component"; import { PlatformBenefitsComponent } from "./offerings/platform-benefits/platform-benefits.component"; import { AdminComponent } from './pages/admin/admin.component'; +import { AnalyticsConfigComponent } from './pages/admin/analytics-config/analytics-config.component'; import { CategoriesComponent } from './pages/admin/categories/categories.component'; 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'; @@ -146,6 +148,7 @@ import { RequestValidationModalComponent } from './pages/seller-offerings/offeri ErrorMessageComponent, CartCardComponent, AdminComponent, + AnalyticsConfigComponent, CategoriesComponent, CreateCategoryComponent, UpdateCategoryComponent, @@ -153,6 +156,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..4ecb89d3 --- /dev/null +++ b/src/app/data/featuresConfig.ts @@ -0,0 +1,97 @@ +import { environment } from 'src/environments/environment'; + +export type FeatureFlagKey = + | 'purchaseEnabled' + | 'quotesEnabled' + | 'tenderingEnabled' + | 'dataSpaceEnabled' + | 'launchValidationEnabled' + | 'tenderDevButtonsOpenCloseEnabled' + | 'aiEnabled'; + +export type RuntimeFeatureFlagKey = FeatureFlagKey | 'searchEnabled'; + +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: 'aiEnabled', + label: 'AI search', + description: 'Enable AI-assisted catalog search.' + }, + { + key: 'tenderingEnabled', + label: 'Tendering', + description: 'Enable tendering features.' + }, + { + 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 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 result: FeaturesConfig = {}; + + 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; +} + +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.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.aiEnabled ?? false; +} diff --git a/src/app/pages/admin/admin.component.html b/src/app/pages/admin/admin.component.html index 241a4379..66c38fe6 100644 --- a/src/app/pages/admin/admin.component.html +++ b/src/app/pages/admin/admin.component.html @@ -45,9 +45,15 @@

{{ 'ADMIN._email' | translate }} +
  • + Analytics +
  • Search & filters
  • +
  • + Features +
  • @@ -84,9 +90,15 @@

    {{ 'ADMIN._email' | translate }} - + + @@ -110,9 +122,15 @@

    +

    Analytics

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

    Analytics enabled

    +

    analyticsEnabled

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

    Dashboard IDs

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

    Superset guest token

    +
    + + +
    +
    +
    + + @if(showSuccess){ + + } + +
    + +
    + } + + +@if(showError){ + +} 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 new file mode 100644 index 00000000..1faafe66 --- /dev/null +++ b/src/app/pages/admin/analytics-config/analytics-config.component.ts @@ -0,0 +1,330 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +type AnalyticsDashboardKey = 'businessInsightsNonLear' | 'businessInsightsLear' | 'usageMonitor'; +const DEFAULT_GUEST_TOKEN_PATH = '/api/v1/dome/guest_token/'; + +interface AnalyticsConfigPayload { + analytics: string; + analyticsEnabled: boolean; + analyticsDashboards: Record; + analyticsSuperset: { + guestTokenPath: string; + }; +} + +@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 = ''; + providedJson = ''; + private successTimeoutId: ReturnType | null = null; + + analyticsForm = new FormGroup({ + analyticsEnabled: new FormControl(false, { nonNullable: true }), + analytics: 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({ + guestTokenPath: new FormControl(DEFAULT_GUEST_TOKEN_PATH, { + nonNullable: true, + validators: [Validators.required] + }) + }) + }); + + constructor(private http: HttpClient) {} + + ngOnInit(): void { + void this.loadConfig(); + } + + ngOnDestroy(): void { + if (this.successTimeoutId) { + clearTimeout(this.successTimeoutId); + this.successTimeoutId = null; + } + } + + 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 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(); + await this.saveAnalyticsPayload(payload); + await this.syncFromBackend(); + + 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; + } + } + + 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); + 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; + } + + 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.analytics = response?.analytics ?? payload.analytics; + environment.analyticsEnabled = response?.analyticsEnabled ?? payload.analyticsEnabled; + } + + private loadAnalyticsConfig(config: any): void { + this.analyticsForm.patchValue({ + analyticsEnabled: typeof config?.analyticsEnabled === 'boolean' + ? config.analyticsEnabled + : false, + analytics: this.readAnalyticsUrl(config), + analyticsDashboards: { + businessInsightsNonLear: this.readString(config?.analyticsDashboards?.businessInsightsNonLear), + businessInsightsLear: this.readString(config?.analyticsDashboards?.businessInsightsLear), + usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) + }, + analyticsSuperset: { + guestTokenPath: this.readGuestTokenPath(config) + } + }); + } + + private buildPayload(): AnalyticsConfigPayload { + const superset = this.analyticsForm.get('analyticsSuperset') as FormGroup; + const dashboards = this.analyticsForm.get('analyticsDashboards') as FormGroup; + + const payload: AnalyticsConfigPayload = { + 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: { + guestTokenPath: this.requireString(superset.get('guestTokenPath')?.value, 'Guest token path is required.') + } + }; + + return payload; + } + + private normalizeProvidedAnalyticsForSave(parsed: any): AnalyticsConfigPayload { + const superset = parsed?.analyticsSuperset && typeof parsed.analyticsSuperset === 'object' + ? parsed.analyticsSuperset + : {}; + + const payload: AnalyticsConfigPayload = { + 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: { + guestTokenPath: this.requireString(superset.guestTokenPath, 'Guest token path is required.') + } + }; + + return payload; + } + + private normalizeAnalyticsForJson(config: any): any { + const analyticsSuperset = config?.analyticsSuperset && typeof config.analyticsSuperset === 'object' + ? config.analyticsSuperset + : {}; + const normalized = { + analyticsEnabled: config?.analyticsEnabled === true, + analytics: this.readAnalyticsUrl(config), + analyticsDashboards: { + businessInsightsNonLear: this.readString(config?.analyticsDashboards?.businessInsightsNonLear), + businessInsightsLear: this.readString(config?.analyticsDashboards?.businessInsightsLear), + usageMonitor: this.readString(config?.analyticsDashboards?.usageMonitor) + }, + analyticsSuperset: { + guestTokenPath: this.readString(analyticsSuperset.guestTokenPath) || DEFAULT_GUEST_TOKEN_PATH + } + }; + + return normalized; + } + + 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) { + throw new Error(message); + } + + 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 : ''; + } + + 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 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 { + 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/admin/features-config/features-config.component.css b/src/app/pages/admin/features-config/features-config.component.css new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/app/pages/admin/features-config/features-config.component.css @@ -0,0 +1 @@ + diff --git a/src/app/pages/admin/features-config/features-config.component.html b/src/app/pages/admin/features-config/features-config.component.html new file mode 100644 index 00000000..bc6203d1 --- /dev/null +++ b/src/app/pages/admin/features-config/features-config.component.html @@ -0,0 +1,56 @@ +
    +

    Features

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

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

    +

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

    +

    {{ flagControl.get('key')?.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..ea999e4a --- /dev/null +++ b/src/app/pages/admin/features-config/features-config.component.ts @@ -0,0 +1,160 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormArray, FormControl, FormGroup } 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; +}>; + +@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(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; + } + } + + 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)); + } + } + + private createFeatureGroup(key: string, enabled: boolean): FeatureFlagFormGroup { + return new FormGroup({ + key: new FormControl(key, { nonNullable: true }), + enabled: new FormControl(enabled, { 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..629c86b4 --- /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 analytics URL 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..a1dd8df4 --- /dev/null +++ b/src/app/pages/analytics/analytics.component.ts @@ -0,0 +1,232 @@ +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.analytics ?? ''; + } + + get analyticsEnabled(): boolean { + return environment.analyticsEnabled; + } + + get hasDashboardConfig(): boolean { + return this.analyticsEnabled && 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.analyticsEnabled) { + this.loading = false; + this.statusMessage = 'Analytics is disabled.'; + return; + } + + 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 90a6c931..b19d167f 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; @@ -39,23 +38,19 @@ export class AppInitService { environment.ADMIN_ROLE = config.roles?.admin; environment.ORG_ADMIN_ROLE = config.roles?.orgAdmin; environment.CERTIFIER_ROLE = config.roles?.certifier; - environment.quoteApi = config.quoteApi ?? environment.quoteApi; + environment.quoteApi = config.quoteApi ?? 'http://localhost:8080/quoteManagement'; + environment.analyticsEnabled = config.analyticsEnabled ?? false; + 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; environment.googleTagManagerId = config.googleTagManagerId ?? ''; - environment.analytics = config.analytics ?? 'https://analytics.dome-marketplace-sbx.org/', - 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); }), @@ -67,4 +62,16 @@ export class AppInitService { this.http.get(`${environment.BASE_URL}/config`).subscribe(obs); }); } + + 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/app/shared/header/header.component.html b/src/app/shared/header/header.component.html index a94a1ec8..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)) {