From fc89f0b76ef985422238f5d603261e269de18c41 Mon Sep 17 00:00:00 2001 From: thekevinm Date: Sat, 2 Aug 2025 22:10:52 +0000 Subject: [PATCH 1/6] Removed video on home page and changed wiki to link to docs. --- package.json | 2 +- .../df-generate-api-card.component.scss | 29 ++++++++----- .../df-welcome-page.component.html | 43 ------------------- .../df-welcome-page.component.scss | 20 +++------ src/app/shared/constants/home.ts | 2 +- 5 files changed, 26 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index 02241f7e..c0630204 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve --proxy-config proxy.conf.json", + "start": "ng serve --host 0.0.0.0 --proxy-config proxy.conf.json", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "jest --verbose", diff --git a/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss b/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss index f8f51e9f..131ce9f5 100644 --- a/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss +++ b/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss @@ -1,36 +1,43 @@ .df-generate-api-card { cursor: pointer; width: 100%; - min-height: 115px; + min-height: 160px; display: flex; flex-direction: column; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } } .df-generate-api-card-content { display: flex; flex-direction: column; align-items: left; - padding: 12px; + padding: 20px; flex-grow: 1; overflow: hidden; fa-icon { - margin-bottom: 8px; + margin-bottom: 12px; flex-shrink: 0; + font-size: 2rem; } .df-card-header { - font-size: 14px; - font-weight: 500; - margin-bottom: 0px; - line-height: 1.2; + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; + line-height: 1.3; } .df-card-description { - font-size: 12px; - line-height: 1.4; + font-size: 14px; + line-height: 1.5; display: -webkit-box; - line-clamp: 2; - -webkit-line-clamp: 2; + line-clamp: 3; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html index c0edaf1b..35e5ec55 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html @@ -22,9 +22,6 @@

[cardFinalHeaderColor]="card.headerColor"> -

- {{ 'home.welcomePage.needHelpChoosing' | transloco }} -

{{ 'home.welcomePage.learnMoreHeading' | transloco }}

@@ -46,46 +43,6 @@

-
diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss b/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss index f28920c5..f7b38a9f 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss @@ -24,14 +24,14 @@ $df-coral-palette: mat.define-palette(theme.$df-coral-palette); } .flex-column { - flex-basis: 50%; + flex-basis: 100%; &:first-child { - padding-right: 25px; + padding-right: 0; } &:last-child { - padding-left: 25px; + padding-left: 0; } } @@ -119,31 +119,23 @@ mat-card.notice-card { df-generate-api-card { width: 32%; + min-height: 180px; - @media (min-width: 960px) and (max-width: 1480px) { + @media (max-width: 959px) { width: 48%; } @media (max-width: 600px) { - width: 48%; - } - - @media (max-width: 350px) { - width: 98%; + width: 100%; } } } -#need-help-choosing-note, #learn-more-heading { text-align: center; margin: 0; } -#need-help-choosing-note { - font-size: 12px; -} - .learn-more-links-list { display: flex; justify-content: space-evenly; diff --git a/src/app/shared/constants/home.ts b/src/app/shared/constants/home.ts index 2a9803c4..b9bd6785 100644 --- a/src/app/shared/constants/home.ts +++ b/src/app/shared/constants/home.ts @@ -30,7 +30,7 @@ const videoTutorials = { const fullDocumentation = { name: 'home.resourceLinks.fullDocumentation', icon: faBook, - link: 'https://wiki.dreamfactory.com/', + link: 'https://docs.dreamfactory.com/', }; const communityForum = { From a47bba16aa7797ef5b57e1faabdaf0ce3ea69d8f Mon Sep 17 00:00:00 2001 From: thekevinm Date: Sun, 3 Aug 2025 00:10:29 +0000 Subject: [PATCH 2/6] Updated home page to include a Quick Stats section to showcase number of APIs, Keys, and Roles. --- package-lock.json | 19 ++ package.json | 1 + .../df-dashboard-card.component.html | 27 +++ .../df-dashboard-card.component.scss | 165 ++++++++++++++++++ .../df-dashboard-card.component.ts | 29 +++ .../df-dashboard/df-dashboard.component.html | 56 ++++++ .../df-dashboard/df-dashboard.component.scss | 83 +++++++++ .../df-dashboard/df-dashboard.component.ts | 99 +++++++++++ .../df-generate-api-card.component.scss | 4 +- .../df-welcome-page.component.html | 8 + .../df-welcome-page.component.scss | 5 + .../df-welcome-page.component.ts | 2 + .../shared/services/df-analytics.service.ts | 154 ++++++++++++++++ src/assets/i18n/home/en.json | 22 +++ 14 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html create mode 100644 src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss create mode 100644 src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts create mode 100644 src/app/adf-home/df-dashboard/df-dashboard.component.html create mode 100644 src/app/adf-home/df-dashboard/df-dashboard.component.scss create mode 100644 src/app/adf-home/df-dashboard/df-dashboard.component.ts create mode 100644 src/app/shared/services/df-analytics.service.ts diff --git a/package-lock.json b/package-lock.json index 78413ded..c9e8f331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@ngneat/transloco": "^5.0.7", "@ngneat/until-destroy": "^10.0.0", "ace-builds": "^1.24.2", + "chart.js": "^4.4.1", "rxjs": "~7.8.0", "source-map-support": "^0.5.21", "swagger-ui": "^5.6.1", @@ -4165,6 +4166,12 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -8139,6 +8146,18 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", diff --git a/package.json b/package.json index c0630204..77bde497 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@ngneat/transloco": "^5.0.7", "@ngneat/until-destroy": "^10.0.0", "ace-builds": "^1.24.2", + "chart.js": "^4.4.1", "rxjs": "~7.8.0", "source-map-support": "^0.5.21", "swagger-ui": "^5.6.1", diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html new file mode 100644 index 00000000..22ff3a15 --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html @@ -0,0 +1,27 @@ + + +
+
+ +
+
+ + + {{ trend }}% + +
+
+ +
+

{{ title }}

+
{{ value }}
+

{{ subtitle }}

+ +
+ + +
+
\ No newline at end of file diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss new file mode 100644 index 00000000..28e5fe70 --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss @@ -0,0 +1,165 @@ +.dashboard-card { + height: 100%; + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + cursor: default; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + } + + .mat-card-content { + padding: 20px; + } +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; +} + +.icon-container { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + + fa-icon { + font-size: 24px; + color: white; + } + + &.icon-primary { + background: linear-gradient(135deg, #7f11e0 0%, #5c239a 100%); + } + + &.icon-accent { + background: linear-gradient(135deg, #ff4081 0%, #e91e63 100%); + } + + &.icon-success { + background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%); + } + + &.icon-info { + background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%); + } + + &.icon-warn { + background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%); + } +} + +.header-stats { + .trend { + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + + &.trend-up { + color: #4caf50; + } + + &.trend-down { + color: #f44336; + } + + fa-icon { + font-size: 12px; + } + } +} + +.card-body { + .card-title { + font-size: 14px; + font-weight: 400; + color: #666; + margin: 0 0 8px 0; + } + + .card-value { + font-size: 32px; + font-weight: 600; + color: #333; + line-height: 1.2; + } + + .card-subtitle { + font-size: 12px; + color: #999; + margin: 4px 0 0 0; + } +} + +.card-footer { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(0, 0, 0, 0.1); + + .footer-text { + font-size: 12px; + color: #666; + } +} + +// Prompt styling +::ng-deep [prompt] { + font-size: 13px; + color: #7f11e0; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + animation: pulse 2s infinite; + + &:before { + content: '👆'; + font-size: 16px; + } +} + +@keyframes pulse { + 0% { opacity: 0.8; } + 50% { opacity: 1; } + 100% { opacity: 0.8; } +} + +// Dark theme +:host-context(.dark-theme) { + .dashboard-card { + background-color: #424242; + + .card-body { + .card-title { + color: #bbb; + } + + .card-value { + color: #fff; + } + + .card-subtitle { + color: #999; + } + } + + .card-footer { + border-top-color: rgba(255, 255, 255, 0.1); + + .footer-text { + color: #bbb; + } + } + } + + ::ng-deep [prompt] { + color: #bb86fc; + } +} \ No newline at end of file diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts new file mode 100644 index 00000000..32ada0fe --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts @@ -0,0 +1,29 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +@Component({ + selector: 'df-dashboard-card', + templateUrl: './df-dashboard-card.component.html', + styleUrls: ['./df-dashboard-card.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatCardModule, + FontAwesomeModule + ] +}) +export class DfDashboardCardComponent { + @Input() icon!: IconDefinition; + @Input() title!: string; + @Input() value!: number | string; + @Input() subtitle?: string; + @Input() trend?: number; + @Input() trendIcon?: IconDefinition; + @Input() trendClass?: string; + @Input() footerText?: string; + @Input() showPrompt?: boolean = false; + @Input() color: 'primary' | 'accent' | 'success' | 'info' | 'warn' = 'primary'; +} \ No newline at end of file diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.html b/src/app/adf-home/df-dashboard/df-dashboard.component.html new file mode 100644 index 00000000..3718a0ff --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.html @@ -0,0 +1,56 @@ +
+

{{ 'home.dashboard.title' | transloco }}

+ +
+ + +

{{ 'home.dashboard.loading' | transloco }}

+
+
+
+ +
+ +
+ + +
+ {{ 'home.dashboard.services.createPrompt' | transloco }} +
+
+ + + + + + + + +
+ +
+ +
+ + +

{{ 'home.dashboard.error' | transloco }}

+
+
+
+
\ No newline at end of file diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.scss b/src/app/adf-home/df-dashboard/df-dashboard.component.scss new file mode 100644 index 00000000..e03c8af0 --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.scss @@ -0,0 +1,83 @@ +.dashboard-container { + padding: 24px; + max-width: 1400px; + margin: 0 auto; + + &.dark-theme { + .dashboard-title { + color: #ffffff; + } + + .loading-card, + .error-card { + background-color: #424242; + } + + .performance-card, + .storage-card { + background-color: #424242; + color: #ffffff; + } + } +} + +.dashboard-title { + font-size: 24px; + font-weight: 500; + margin-bottom: 24px; + color: #333; +} + +.loading-container, +.error-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.loading-card, +.error-card { + text-align: center; + padding: 32px; +} + + +.error-icon { + font-size: 48px; + color: #f44336; + margin-bottom: 16px; +} + +.dashboard-content { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + + + +// Mobile responsiveness +@media (max-width: 768px) { + .dashboard-container { + padding: 16px; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .charts-row { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.ts b/src/app/adf-home/df-dashboard/df-dashboard.component.ts new file mode 100644 index 00000000..01b65ae7 --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.ts @@ -0,0 +1,99 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDividerModule } from '@angular/material/divider'; +import { TranslocoModule } from '@ngneat/transloco'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { + faPlug, + faKey, + faLock +} from '@fortawesome/free-solid-svg-icons'; +import { Subject, takeUntil } from 'rxjs'; +import { DfAnalyticsService, DashboardStats } from '../../shared/services/df-analytics.service'; +import { DfThemeService } from '../../shared/services/df-theme.service'; +import { DfBreakpointService } from '../../shared/services/df-breakpoint.service'; +import { DfDashboardCardComponent } from './df-dashboard-card/df-dashboard-card.component'; + +@Component({ + selector: 'df-dashboard', + templateUrl: './df-dashboard.component.html', + styleUrls: ['./df-dashboard.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatIconModule, + MatProgressBarModule, + MatTooltipModule, + MatDividerModule, + TranslocoModule, + FontAwesomeModule, + DfDashboardCardComponent + ] +}) +export class DfDashboardComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + // Icons + faPlug = faPlug; // For API Services + faKey = faKey; // For API Keys + faLock = faLock; // For Roles + + stats: DashboardStats = { + services: { total: 0 }, + apiKeys: { total: 0 }, + roles: { total: 0 } + }; + loading = true; + error = false; + + constructor( + private analyticsService: DfAnalyticsService, + public themeService: DfThemeService, + public breakpointService: DfBreakpointService + ) {} + + ngOnInit(): void { + // Clear cache to ensure fresh data + localStorage.removeItem('df_dashboard_stats'); + this.loadDashboardStats(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadDashboardStats(): void { + this.loading = true; + this.error = false; + + this.analyticsService.getDashboardStats() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (stats) => { + this.stats = stats || { + services: { total: 0 }, + apiKeys: { total: 0 }, + roles: { total: 0 } + }; + this.loading = false; + }, + error: () => { + this.error = true; + this.loading = false; + // Keep default stats even on error + this.stats = { + services: { total: 0 }, + apiKeys: { total: 0 }, + roles: { total: 0 } + }; + } + }); + } + +} \ No newline at end of file diff --git a/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss b/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss index 131ce9f5..2369516d 100644 --- a/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss +++ b/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss @@ -4,7 +4,9 @@ min-height: 160px; display: flex; flex-direction: column; - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; &:hover { transform: translateY(-2px); diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html index 35e5ec55..50d60db1 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html @@ -22,6 +22,14 @@

[cardFinalHeaderColor]="card.headerColor">

+ + + +
+ +
+ +

{{ 'home.welcomePage.learnMoreHeading' | transloco }}

diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss b/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss index f7b38a9f..787637fe 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss @@ -142,3 +142,8 @@ mat-card.notice-card { flex-wrap: wrap; gap: 10px; } + +.dashboard-section { + margin-top: 48px; + margin-bottom: 32px; +} diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts b/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts index a5d6ff9b..549685c6 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts @@ -40,6 +40,7 @@ import { SERVICES_SERVICE_TOKEN } from 'src/app/shared/constants/tokens'; import { DFStorageService } from 'src/app/shared/services/df-storage.service'; import { ROUTES } from '../../shared/types/routes'; import { DfGenerateApiCardComponent } from './df-generate-api-card/df-generate-api-card.component'; +import { DfDashboardComponent } from '../df-dashboard/df-dashboard.component'; @Component({ selector: 'df-welcome-page', @@ -63,6 +64,7 @@ import { DfGenerateApiCardComponent } from './df-generate-api-card/df-generate-a MatButtonModule, MatIconModule, DfGenerateApiCardComponent, + DfDashboardComponent, ], providers: [DfBaseCrudService], }) diff --git a/src/app/shared/services/df-analytics.service.ts b/src/app/shared/services/df-analytics.service.ts new file mode 100644 index 00000000..1b02ccce --- /dev/null +++ b/src/app/shared/services/df-analytics.service.ts @@ -0,0 +1,154 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of, timer, forkJoin } from 'rxjs'; +import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators'; + +export interface DashboardStats { + services: { + total: number; + }; + apiKeys: { + total: number; + }; + roles: { + total: number; + }; +} + +@Injectable({ + providedIn: 'root' +}) +export class DfAnalyticsService { + private readonly CACHE_KEY = 'df_dashboard_stats'; + private readonly CACHE_DURATION = 30 * 1000; // 30 seconds + private readonly REFRESH_INTERVAL = 15 * 60 * 1000; // 15 minutes + + private stats$: Observable; + + constructor(private http: HttpClient) { + // Initialize the stats observable with automatic refresh + this.stats$ = timer(0, this.REFRESH_INTERVAL).pipe( + switchMap(() => this.fetchStats()), + shareReplay(1) + ); + } + + getDashboardStats(): Observable { + // Check localStorage cache first + const cached = this.getCachedStats(); + if (cached) { + return of(cached); + } + + return this.stats$; + } + + private fetchStats(): Observable { + // Fetch minimal data to filter out system services + const requests = { + services: this.http.get('/api/v2/system/service?fields=id,name,type&include_count=true'), + roles: this.http.get('/api/v2/system/role?fields=id,name&include_count=true'), + appKeys: this.http.get('/api/v2/system/app?include_count=true') + }; + + return forkJoin(requests).pipe( + map(responses => this.transformResponses(responses)), + tap(stats => this.cacheStats(stats)), + catchError(() => { + // Return simple fallback data if API fails + return of(this.getSimpleStats()); + }) + ); + } + + private transformResponses(responses: any): DashboardStats { + const { services, roles, appKeys } = responses; + + // System service names to exclude (comprehensive list of DreamFactory system services) + // System service names to exclude (comprehensive list of DreamFactory system services) + const systemServiceNames = [ + 'system', 'api_docs', 'files', 'logs', 'db', 'email', 'user', 'script', + 'ui', 'schema', 'api_doc', 'file', 'log', 'admin', 'df-admin', + 'dreamfactory', 'cache', 'push', 'pub_sub' + ].map(s => s.toLowerCase()); + // Common system app names - being very specific to avoid filtering user apps + const systemAppNames = ['admin', 'api_docs', 'file_manager'].map(s => s.toLowerCase()); + const systemRoleNames = ['administrator', 'user', 'admin', 'sys_admin'].map(s => s.toLowerCase()); + + // Filter services - exclude system services by name + const userServices = (services.resource || []).filter((s: any) => { + return !systemServiceNames.includes(s.name.toLowerCase()); + }); + + // Filter API Keys - exclude system apps by name + const userApiKeys = (appKeys.resource || []).filter((a: any) => { + // Check multiple possible field names for API key + const apiKeyValue = a.apiKey || a.api_key || a.apikey; + const hasApiKey = !!apiKeyValue; + const isSystemApp = systemAppNames.includes(a.name.toLowerCase()); + + return !isSystemApp && hasApiKey; + }); + + // Filter roles - exclude system roles by name + const userRoles = (roles.resource || []).filter((r: any) => { + return !systemRoleNames.includes(r.name.toLowerCase()); + }); + + return { + services: { + total: userServices.length + }, + apiKeys: { + total: userApiKeys.length + }, + roles: { + total: userRoles.length + } + }; + } + + private calculateTrend(previous: number, current: number): number { + if (previous === 0) return 0; + return Math.round(((current - previous) / previous) * 100); + } + + private getCachedStats(): DashboardStats | null { + const cached = localStorage.getItem(this.CACHE_KEY); + if (!cached) return null; + + try { + const { data, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < this.CACHE_DURATION) { + return data; + } + } catch { + // Invalid cache + } + + localStorage.removeItem(this.CACHE_KEY); + return null; + } + + private cacheStats(stats: DashboardStats): void { + localStorage.setItem(this.CACHE_KEY, JSON.stringify({ + data: stats, + timestamp: Date.now() + })); + } + + private getSimpleStats(): DashboardStats { + // Return simple fallback data + return { + services: { + total: 0 + }, + apiKeys: { + total: 0 + }, + roles: { + total: 0 + } + }; + } +} \ No newline at end of file diff --git a/src/assets/i18n/home/en.json b/src/assets/i18n/home/en.json index 02b278cb..f79163f1 100644 --- a/src/assets/i18n/home/en.json +++ b/src/assets/i18n/home/en.json @@ -91,5 +91,27 @@ "angularJs": "Angular JS", "angular2": "Angular 2", "react": "React" + }, + "dashboard": { + "title": "Quick Stats", + "loading": "Loading stats...", + "error": "Unable to load stats. Please try again later.", + "services": { + "title": "API Services", + "total": "Your Services", + "createPrompt": "Get started! Create your first API using the cards above" + }, + "admins": { + "title": "Admins", + "total": "Total Admins" + }, + "apiKeys": { + "title": "API Keys", + "total": "Your API Keys" + }, + "roles": { + "title": "Roles", + "total": "Your Roles" + } } } \ No newline at end of file From 72743b31e37fa59e75f1d3b7067877bbc3b66085 Mon Sep 17 00:00:00 2001 From: thekevinm Date: Tue, 5 Aug 2025 21:42:24 +0000 Subject: [PATCH 3/6] Fix duplicate functionality: wrap API payloads in resource array and copy all configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed API request format by wrapping payloads in { resource: [data] } as expected by backend - Updated role duplication to copy all permissions (role_service_access_by_role_id) and lookup keys (lookup_by_role_id) - Updated service duplication to copy entire config object with all service-specific settings - Updated app duplication to copy all app configurations including type, url, storage settings, etc. - Added proper error handling with catchError and throwError for all API calls - Use snake_case for API fields (is_active, role_id, api_key, etc.) - Apps generate new API keys when duplicated - All duplicate operations show appropriate success messages 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../df-manage-apps-table.component.ts | 111 +++++++++++++++++- .../df-dashboard-card.component.html | 10 +- .../df-dashboard-card.component.scss | 28 +++-- .../df-dashboard-card.component.ts | 11 +- .../df-dashboard/df-dashboard.component.html | 9 +- .../df-dashboard/df-dashboard.component.scss | 15 ++- .../df-dashboard/df-dashboard.component.ts | 41 ++++--- .../df-welcome-page.component.html | 4 +- .../df-manage-roles-table.component.ts | 101 ++++++++++++++++ .../df-manage-services-table.component.ts | 89 ++++++++++++++ .../df-duplicate-dialog.component.html | 38 ++++++ .../df-duplicate-dialog.component.scss | 3 + .../df-duplicate-dialog.component.ts | 78 ++++++++++++ .../shared/services/df-analytics.service.ts | 89 +++++++++----- src/assets/i18n/en.json | 39 ++++++ 15 files changed, 580 insertions(+), 86 deletions(-) create mode 100644 src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html create mode 100644 src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss create mode 100644 src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts diff --git a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts index 0673ea89..e3328238 100644 --- a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts +++ b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts @@ -17,6 +17,9 @@ import { generateApiKey } from 'src/app/shared/utilities/hash'; import { DfSystemConfigDataService } from 'src/app/shared/services/df-system-config-data.service'; import { AdditonalAction } from 'src/app/shared/types/table'; import { DfSnackbarService } from 'src/app/shared/services/df-snackbar.service'; +import { DfDuplicateDialogComponent } from 'src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component'; +import { faCopy } from '@fortawesome/free-solid-svg-icons'; +import { catchError, throwError } from 'rxjs'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -81,10 +84,34 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { disabled: row => row.createdById === null, }, ]; + + // Add duplicate action before delete action + const duplicateAction = { + label: 'duplicate', + function: (row: AppRow) => this.duplicateApp(row), + ariaLabel: { + key: 'duplicateApp', + param: 'name', + }, + icon: faCopy, + }; + if (this.actions.additional) { + // Find the delete action index + const deleteIndex = this.actions.additional.findIndex( + action => action.label === 'delete' + ); + if (deleteIndex !== -1) { + // Insert duplicate before delete + this.actions.additional.splice(deleteIndex, 0, duplicateAction); + } else { + // Add at the beginning if no delete found + this.actions.additional.unshift(duplicateAction); + } + // Add the extra actions at the end this.actions.additional.push(...extraActions); } else { - this.actions.additional = extraActions; + this.actions.additional = [duplicateAction, ...extraActions]; } } override columns = [ @@ -149,4 +176,86 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { this.tableLength = data.meta.count; }); } + + duplicateApp(row: AppRow): void { + // First, get the full app details + this.appsService.get(row.id) + .pipe( + catchError(error => { + console.error('Failed to fetch app details:', error); + return throwError(() => error); + }) + ) + .subscribe(app => { + // Get all existing app names for validation + this.appsService + .getAll>({ limit: 1000 }) + .subscribe(allApps => { + const existingNames = allApps.resource.map(a => a.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'apps.duplicate.title', + message: 'apps.duplicate.message', + label: 'apps.duplicate.nameLabel', + originalName: app.name, + existingNames: existingNames, + }, + }); + + dialogRef.afterClosed().subscribe(async (newName) => { + if (newName) { + // Generate a new API key for the duplicated app + const newApiKey = await generateApiKey( + this.systemConfigDataService.environment.server.host, + newName + ); + + // Create a copy of the app with the new name and API key + // Using snake_case as expected by the API + const duplicatedApp = { + name: newName, + api_key: newApiKey, + description: `${app.description || ''} (copy)`, + is_active: app.isActive, + type: app.type, + role_id: app.roleId || null, + // Copy app location specific fields + url: app.url || null, + storage_service_id: app.storageServiceId || null, + storage_container: app.storageContainer || null, + path: app.path || null, + // Copy additional settings + requires_fullscreen: app.requiresFullscreen, + allow_fullscreen_toggle: app.allowFullscreenToggle, + toggle_location: app.toggleLocation, + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedApp] + }; + + // Create the new app + this.appsService + .create(payload, { + snackbarSuccess: 'apps.alerts.duplicateSuccess', + fields: '*', + related: 'role_by_role_id' + }) + .pipe( + catchError(error => { + console.error('Failed to duplicate app:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); + }); + }); + } } diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html index 22ff3a15..4626c566 100644 --- a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html @@ -11,17 +11,19 @@ - +

{{ title }}

{{ value }}

{{ subtitle }}

- + - \ No newline at end of file + diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss index 28e5fe70..8b9ebee6 100644 --- a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss @@ -1,8 +1,10 @@ .dashboard-card { height: 100%; - transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + transition: + transform 0.2s ease-in-out, + box-shadow 0.2s ease-in-out; cursor: default; - + &:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); @@ -27,7 +29,7 @@ display: flex; align-items: center; justify-content: center; - + fa-icon { font-size: 24px; color: white; @@ -118,7 +120,7 @@ align-items: center; gap: 4px; animation: pulse 2s infinite; - + &:before { content: '👆'; font-size: 16px; @@ -126,16 +128,22 @@ } @keyframes pulse { - 0% { opacity: 0.8; } - 50% { opacity: 1; } - 100% { opacity: 0.8; } + 0% { + opacity: 0.8; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.8; + } } // Dark theme :host-context(.dark-theme) { .dashboard-card { background-color: #424242; - + .card-body { .card-title { color: #bbb; @@ -158,8 +166,8 @@ } } } - + ::ng-deep [prompt] { color: #bb86fc; } -} \ No newline at end of file +} diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts index 32ada0fe..1070453d 100644 --- a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts @@ -9,11 +9,7 @@ import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; templateUrl: './df-dashboard-card.component.html', styleUrls: ['./df-dashboard-card.component.scss'], standalone: true, - imports: [ - CommonModule, - MatCardModule, - FontAwesomeModule - ] + imports: [CommonModule, MatCardModule, FontAwesomeModule], }) export class DfDashboardCardComponent { @Input() icon!: IconDefinition; @@ -25,5 +21,6 @@ export class DfDashboardCardComponent { @Input() trendClass?: string; @Input() footerText?: string; @Input() showPrompt?: boolean = false; - @Input() color: 'primary' | 'accent' | 'success' | 'info' | 'warn' = 'primary'; -} \ No newline at end of file + @Input() color: 'primary' | 'accent' | 'success' | 'info' | 'warn' = + 'primary'; +} diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.html b/src/app/adf-home/df-dashboard/df-dashboard.component.html index 3718a0ff..adef0bcf 100644 --- a/src/app/adf-home/df-dashboard/df-dashboard.component.html +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.html @@ -1,6 +1,8 @@ -
+

{{ 'home.dashboard.title' | transloco }}

- +
@@ -43,7 +45,6 @@

{{ 'home.dashboard.title' | transloco }}

color="info">
-
@@ -53,4 +54,4 @@

{{ 'home.dashboard.title' | transloco }}

-
\ No newline at end of file + diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.scss b/src/app/adf-home/df-dashboard/df-dashboard.component.scss index e03c8af0..f509ec37 100644 --- a/src/app/adf-home/df-dashboard/df-dashboard.component.scss +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.scss @@ -42,7 +42,6 @@ padding: 32px; } - .error-icon { font-size: 48px; color: #f44336; @@ -54,8 +53,14 @@ } @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .stats-grid { @@ -65,8 +70,6 @@ margin-bottom: 24px; } - - // Mobile responsiveness @media (max-width: 768px) { .dashboard-container { @@ -80,4 +83,4 @@ .charts-row { grid-template-columns: 1fr; } -} \ No newline at end of file +} diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.ts b/src/app/adf-home/df-dashboard/df-dashboard.component.ts index 01b65ae7..7b9192fe 100644 --- a/src/app/adf-home/df-dashboard/df-dashboard.component.ts +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.ts @@ -7,13 +7,12 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { TranslocoModule } from '@ngneat/transloco'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { - faPlug, - faKey, - faLock -} from '@fortawesome/free-solid-svg-icons'; +import { faPlug, faKey, faLock } from '@fortawesome/free-solid-svg-icons'; import { Subject, takeUntil } from 'rxjs'; -import { DfAnalyticsService, DashboardStats } from '../../shared/services/df-analytics.service'; +import { + DfAnalyticsService, + DashboardStats, +} from '../../shared/services/df-analytics.service'; import { DfThemeService } from '../../shared/services/df-theme.service'; import { DfBreakpointService } from '../../shared/services/df-breakpoint.service'; import { DfDashboardCardComponent } from './df-dashboard-card/df-dashboard-card.component'; @@ -32,21 +31,21 @@ import { DfDashboardCardComponent } from './df-dashboard-card/df-dashboard-card. MatDividerModule, TranslocoModule, FontAwesomeModule, - DfDashboardCardComponent - ] + DfDashboardCardComponent, + ], }) export class DfDashboardComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); - + // Icons - faPlug = faPlug; // For API Services - faKey = faKey; // For API Keys - faLock = faLock; // For Roles + faPlug = faPlug; // For API Services + faKey = faKey; // For API Keys + faLock = faLock; // For Roles stats: DashboardStats = { services: { total: 0 }, apiKeys: { total: 0 }, - roles: { total: 0 } + roles: { total: 0 }, }; loading = true; error = false; @@ -71,15 +70,16 @@ export class DfDashboardComponent implements OnInit, OnDestroy { private loadDashboardStats(): void { this.loading = true; this.error = false; - - this.analyticsService.getDashboardStats() + + this.analyticsService + .getDashboardStats() .pipe(takeUntil(this.destroy$)) .subscribe({ - next: (stats) => { + next: stats => { this.stats = stats || { services: { total: 0 }, apiKeys: { total: 0 }, - roles: { total: 0 } + roles: { total: 0 }, }; this.loading = false; }, @@ -90,10 +90,9 @@ export class DfDashboardComponent implements OnInit, OnDestroy { this.stats = { services: { total: 0 }, apiKeys: { total: 0 }, - roles: { total: 0 } + roles: { total: 0 }, }; - } + }, }); } - -} \ No newline at end of file +} diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html index 50d60db1..bea621ea 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html @@ -23,12 +23,12 @@

- +
- +

{{ 'home.welcomePage.learnMoreHeading' | transloco }} diff --git a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts index 4a317908..11a40260 100644 --- a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts +++ b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts @@ -20,6 +20,9 @@ import { animate, transition, } from '@angular/animations'; +import { DfDuplicateDialogComponent } from 'src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component'; +import { faCopy } from '@fortawesome/free-solid-svg-icons'; +import { catchError, throwError } from 'rxjs'; @UntilDestroy({ checkProperties: true }) @Component({ selector: 'df-manage-roles-table', @@ -53,6 +56,29 @@ export class DfManageRolesTableComponent extends DfManageTableComponent dialog: MatDialog ) { super(router, activatedRoute, liveAnnouncer, translateService, dialog); + + // Add duplicate action + const duplicateAction = { + label: 'duplicate', + function: (row: RoleRow) => this.duplicateRole(row), + ariaLabel: { + key: 'duplicateRole', + param: 'name', + }, + icon: faCopy, + }; + + if (this.actions.additional) { + // Insert duplicate action before delete action + const deleteIndex = this.actions.additional.findIndex( + action => action.label === 'delete' + ); + if (deleteIndex !== -1) { + this.actions.additional.splice(deleteIndex, 0, duplicateAction); + } else { + this.actions.additional.push(duplicateAction); + } + } } filterQuery = getFilterQuery('roles'); @@ -103,4 +129,79 @@ export class DfManageRolesTableComponent extends DfManageTableComponent this.tableLength = data.meta.count; }); } + + duplicateRole(row: RoleRow): void { + // First, get the full role details with related data + this.roleService.get(row.id, { related: 'role_service_access_by_role_id,lookup_by_role_id' }) + .pipe( + catchError(error => { + console.error('Failed to fetch role details:', error); + return throwError(() => error); + }) + ) + .subscribe(roleData => { + // Get all existing role names for validation + this.roleService + .getAll>({ limit: 1000 }) + .subscribe(allRoles => { + const existingNames = allRoles.resource.map(r => r.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'roles.duplicate.title', + message: 'roles.duplicate.message', + label: 'roles.duplicate.nameLabel', + originalName: roleData.name, + existingNames: existingNames, + }, + }); + + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the role with all its configurations + const duplicatedRole = { + name: newName, + description: `${roleData.description || ''} (copy)`, + is_active: roleData.is_active, + // Copy service access permissions + role_service_access_by_role_id: roleData.role_service_access_by_role_id?.map((access: any) => ({ + service_id: access.service_id, + component: access.component, + verb_mask: access.verb_mask, + requestor_mask: access.requestor_mask, + filters: access.filters, + filter_op: access.filter_op, + })) || [], + // Copy lookup keys + lookup_by_role_id: roleData.lookup_by_role_id?.map((lookup: any) => ({ + name: lookup.name, + value: lookup.value, + private: lookup.private, + description: lookup.description, + })) || [], + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedRole] + }; + + // Create the new role + this.roleService + .create(payload, { snackbarSuccess: 'roles.alerts.duplicateSuccess' }) + .pipe( + catchError(error => { + console.error('Failed to duplicate role:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); + }); + }); + } } diff --git a/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts b/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts index e59be6bd..f144836e 100644 --- a/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts +++ b/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts @@ -13,6 +13,9 @@ import { GenericListResponse } from 'src/app/shared/types/generic-http'; import { Service, ServiceRow, ServiceType } from 'src/app/shared/types/service'; import { getFilterQuery } from 'src/app/shared/utilities/filter-queries'; import { UntilDestroy } from '@ngneat/until-destroy'; +import { DfDuplicateDialogComponent } from 'src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component'; +import { faCopy } from '@fortawesome/free-solid-svg-icons'; +import { catchError, throwError } from 'rxjs'; @UntilDestroy({ checkProperties: true }) @Component({ selector: 'df-manage-services-table', @@ -49,6 +52,29 @@ export class DfManageServicesTableComponent extends DfManageTableComponent action.label !== 'delete' ) ?? null, }; + } else { + // Add duplicate action for non-system services + const duplicateAction = { + label: 'duplicate', + function: (row: ServiceRow) => this.duplicateService(row), + ariaLabel: { + key: 'duplicateService', + param: 'name', + }, + icon: faCopy, + }; + + if (this.actions.additional) { + // Insert duplicate action before delete action + const deleteIndex = this.actions.additional.findIndex( + action => action.label === 'delete' + ); + if (deleteIndex !== -1) { + this.actions.additional.splice(deleteIndex, 0, duplicateAction); + } else { + this.actions.additional.push(duplicateAction); + } + } } }); } @@ -163,4 +189,67 @@ export class DfManageServicesTableComponent extends DfManageTableComponent(row.id) + .pipe( + catchError(error => { + console.error('Failed to fetch service details:', error); + return throwError(() => error); + }) + ) + .subscribe(service => { + // Get all existing service names for validation + this.serviceService + .getAll>({ limit: 1000 }) + .subscribe(allServices => { + const existingNames = allServices.resource.map(s => s.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'services.duplicate.title', + message: 'services.duplicate.message', + label: 'services.duplicate.nameLabel', + originalName: service.name, + existingNames: existingNames, + }, + }); + + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the service with the new name, including all config + const duplicatedService = { + name: newName, + label: service.label || newName, + description: `${service.description || ''} (copy)`, + is_active: service.isActive, + type: service.type, + // Copy the entire config object which contains all service-specific settings + config: service.config ? { ...service.config } : {}, + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedService] + }; + + // Create the new service + this.serviceService + .create(payload, { snackbarSuccess: 'services.alerts.duplicateSuccess' }) + .pipe( + catchError(error => { + console.error('Failed to duplicate service:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); + }); + }); + } } diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html new file mode 100644 index 00000000..3c78eeba --- /dev/null +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html @@ -0,0 +1,38 @@ +

{{ data.title | transloco }}

+
+

{{ data.message | transloco }}

+ + {{ data.label | transloco }} + + + {{ 'validation.required' | transloco }} + + + {{ 'validation.nameExists' | transloco }} + + + {{ 'validation.sameAsOriginal' | transloco }} + + +
+
+ + +
\ No newline at end of file diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss new file mode 100644 index 00000000..54079260 --- /dev/null +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss @@ -0,0 +1,3 @@ +.full-width { + width: 100%; +} \ No newline at end of file diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts new file mode 100644 index 00000000..b588eef0 --- /dev/null +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts @@ -0,0 +1,78 @@ +import { Component, Inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + FormControl, + ReactiveFormsModule, + Validators, + AbstractControl, + ValidationErrors +} from '@angular/forms'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { TranslocoPipe } from '@ngneat/transloco'; +import { NgIf } from '@angular/common'; + +export interface DuplicateDialogData { + title: string; + message: string; + label: string; + originalName: string; + existingNames?: string[]; +} + +@Component({ + selector: 'df-duplicate-dialog', + templateUrl: './df-duplicate-dialog.component.html', + styleUrls: ['./df-duplicate-dialog.component.scss'], + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + TranslocoPipe, + NgIf, + ], +}) +export class DfDuplicateDialogComponent { + nameControl: FormControl; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: DuplicateDialogData + ) { + this.nameControl = new FormControl('', [ + Validators.required, + this.uniqueNameValidator.bind(this), + ]); + } + + uniqueNameValidator(control: AbstractControl): ValidationErrors | null { + if ( + this.data.existingNames && + this.data.existingNames.includes(control.value) + ) { + return { nameExists: true }; + } + if (control.value === this.data.originalName) { + return { sameName: true }; + } + return null; + } + + onDuplicate(): void { + if (this.nameControl.valid) { + this.dialogRef.close(this.nameControl.value); + } + } + + onCancel(): void { + this.dialogRef.close(null); + } +} \ No newline at end of file diff --git a/src/app/shared/services/df-analytics.service.ts b/src/app/shared/services/df-analytics.service.ts index 1b02ccce..b4808836 100644 --- a/src/app/shared/services/df-analytics.service.ts +++ b/src/app/shared/services/df-analytics.service.ts @@ -16,13 +16,13 @@ export interface DashboardStats { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DfAnalyticsService { private readonly CACHE_KEY = 'df_dashboard_stats'; private readonly CACHE_DURATION = 30 * 1000; // 30 seconds private readonly REFRESH_INTERVAL = 15 * 60 * 1000; // 15 minutes - + private stats$: Observable; constructor(private http: HttpClient) { @@ -39,16 +39,20 @@ export class DfAnalyticsService { if (cached) { return of(cached); } - + return this.stats$; } private fetchStats(): Observable { // Fetch minimal data to filter out system services const requests = { - services: this.http.get('/api/v2/system/service?fields=id,name,type&include_count=true'), - roles: this.http.get('/api/v2/system/role?fields=id,name&include_count=true'), - appKeys: this.http.get('/api/v2/system/app?include_count=true') + services: this.http.get( + '/api/v2/system/service?fields=id,name,type&include_count=true' + ), + roles: this.http.get( + '/api/v2/system/role?fields=id,name&include_count=true' + ), + appKeys: this.http.get('/api/v2/system/app?include_count=true'), }; return forkJoin(requests).pipe( @@ -63,48 +67,68 @@ export class DfAnalyticsService { private transformResponses(responses: any): DashboardStats { const { services, roles, appKeys } = responses; - + // System service names to exclude (comprehensive list of DreamFactory system services) // System service names to exclude (comprehensive list of DreamFactory system services) const systemServiceNames = [ - 'system', 'api_docs', 'files', 'logs', 'db', 'email', 'user', 'script', - 'ui', 'schema', 'api_doc', 'file', 'log', 'admin', 'df-admin', - 'dreamfactory', 'cache', 'push', 'pub_sub' + 'system', + 'api_docs', + 'files', + 'logs', + 'db', + 'email', + 'user', + 'script', + 'ui', + 'schema', + 'api_doc', + 'file', + 'log', + 'admin', + 'df-admin', + 'dreamfactory', + 'cache', + 'push', + 'pub_sub', ].map(s => s.toLowerCase()); // Common system app names - being very specific to avoid filtering user apps - const systemAppNames = ['admin', 'api_docs', 'file_manager'].map(s => s.toLowerCase()); - const systemRoleNames = ['administrator', 'user', 'admin', 'sys_admin'].map(s => s.toLowerCase()); - + const systemAppNames = ['admin', 'api_docs', 'file_manager'].map(s => + s.toLowerCase() + ); + const systemRoleNames = ['administrator', 'user', 'admin', 'sys_admin'].map( + s => s.toLowerCase() + ); + // Filter services - exclude system services by name const userServices = (services.resource || []).filter((s: any) => { return !systemServiceNames.includes(s.name.toLowerCase()); }); - + // Filter API Keys - exclude system apps by name const userApiKeys = (appKeys.resource || []).filter((a: any) => { // Check multiple possible field names for API key const apiKeyValue = a.apiKey || a.api_key || a.apikey; const hasApiKey = !!apiKeyValue; const isSystemApp = systemAppNames.includes(a.name.toLowerCase()); - + return !isSystemApp && hasApiKey; }); - + // Filter roles - exclude system roles by name const userRoles = (roles.resource || []).filter((r: any) => { return !systemRoleNames.includes(r.name.toLowerCase()); }); - + return { services: { - total: userServices.length + total: userServices.length, }, apiKeys: { - total: userApiKeys.length + total: userApiKeys.length, }, roles: { - total: userRoles.length - } + total: userRoles.length, + }, }; } @@ -125,30 +149,33 @@ export class DfAnalyticsService { } catch { // Invalid cache } - + localStorage.removeItem(this.CACHE_KEY); return null; } private cacheStats(stats: DashboardStats): void { - localStorage.setItem(this.CACHE_KEY, JSON.stringify({ - data: stats, - timestamp: Date.now() - })); + localStorage.setItem( + this.CACHE_KEY, + JSON.stringify({ + data: stats, + timestamp: Date.now(), + }) + ); } private getSimpleStats(): DashboardStats { // Return simple fallback data return { services: { - total: 0 + total: 0, }, apiKeys: { - total: 0 + total: 0, }, roles: { - total: 0 - } + total: 0, + }, }; } -} \ No newline at end of file +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 9bd03a0e..c9493ce7 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -87,6 +87,10 @@ "confirm": "Confirm", "label": "Label", "confirmDelete": "Are you sure you want to delete this?", + "duplicate": "Duplicate", + "duplicateService": "Duplicate service {{name}}", + "duplicateRole": "Duplicate role {{name}}", + "duplicateApp": "Duplicate app {{name}}", "origins": "Origins", "headers": "Headers", "exposedHeaders": "Exposed Headers", @@ -377,6 +381,41 @@ "duplicateEmail": "Email address already exists", "close": "Close alert" }, + "validation": { + "required": "This field is required", + "nameExists": "This name already exists", + "sameAsOriginal": "Name must be different from original" + }, + "services": { + "alerts": { + "duplicateSuccess": "Service duplicated successfully" + }, + "duplicate": { + "title": "Duplicate Service", + "message": "Enter a new name for the duplicated service", + "nameLabel": "Service Name" + } + }, + "roles": { + "alerts": { + "duplicateSuccess": "Role duplicated successfully" + }, + "duplicate": { + "title": "Duplicate Role", + "message": "Enter a new name for the duplicated role", + "nameLabel": "Role Name" + } + }, + "apps": { + "alerts": { + "duplicateSuccess": "App duplicated successfully" + }, + "duplicate": { + "title": "Duplicate App", + "message": "Enter a new name for the duplicated app", + "nameLabel": "App Name" + } + }, "lookupKeys": { "label": "Lookup Keys", "desc": "Lookup keys for service configuration and credentials must be made private.", From 30bdeb06f4c980f27b4b2239358d608971f1a122 Mon Sep 17 00:00:00 2001 From: thekevinm Date: Tue, 5 Aug 2025 22:01:41 +0000 Subject: [PATCH 4/6] Fix role and app duplication issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed role duplication not copying permissions: - Changed from snake_case to camelCase field names (roleServiceAccessByRoleId, lookupByRoleId, isActive) - Fixed permission field mapping to use camelCase (serviceId, verbMask, requestorMask) - Fixed filters structure to match API format with name/operator/value - Added fields and related parameters to the create request - Fixed app duplication not working: - Removed api_key from payload (API keys are generated server-side) - Removed unnecessary async/await for API key generation - Fixed refresh API key action to use snake_case api_key Both issues were caused by API field naming convention mismatches. The DreamFactory API expects camelCase for roles and snake_case for apps. 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../df-manage-apps-table.component.ts | 137 ++++++++--------- .../df-manage-roles-table.component.ts | 142 ++++++++++-------- .../df-manage-services-table.component.ts | 103 +++++++------ .../df-duplicate-dialog.component.html | 14 +- .../df-duplicate-dialog.component.scss | 2 +- .../df-duplicate-dialog.component.ts | 10 +- 6 files changed, 209 insertions(+), 199 deletions(-) diff --git a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts index e3328238..616078c7 100644 --- a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts +++ b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts @@ -75,7 +75,7 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { row.name ); this.appsService - .update(row.id, { apiKey: newKey }) + .update(row.id, { api_key: newKey }) .subscribe(() => this.refreshTable()); }, ariaLabel: { @@ -84,7 +84,7 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { disabled: row => row.createdById === null, }, ]; - + // Add duplicate action before delete action const duplicateAction = { label: 'duplicate', @@ -95,7 +95,7 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { }, icon: faCopy, }; - + if (this.actions.additional) { // Find the delete action index const deleteIndex = this.actions.additional.findIndex( @@ -179,7 +179,8 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { duplicateApp(row: AppRow): void { // First, get the full app details - this.appsService.get(row.id) + this.appsService + .get(row.id) .pipe( catchError(error => { console.error('Failed to fetch app details:', error); @@ -187,75 +188,69 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { }) ) .subscribe(app => { - // Get all existing app names for validation - this.appsService - .getAll>({ limit: 1000 }) - .subscribe(allApps => { - const existingNames = allApps.resource.map(a => a.name); - - const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { - width: '400px', - data: { - title: 'apps.duplicate.title', - message: 'apps.duplicate.message', - label: 'apps.duplicate.nameLabel', - originalName: app.name, - existingNames: existingNames, - }, - }); + // Get all existing app names for validation + this.appsService + .getAll>({ limit: 1000 }) + .subscribe(allApps => { + const existingNames = allApps.resource.map(a => a.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'apps.duplicate.title', + message: 'apps.duplicate.message', + label: 'apps.duplicate.nameLabel', + originalName: app.name, + existingNames: existingNames, + }, + }); - dialogRef.afterClosed().subscribe(async (newName) => { - if (newName) { - // Generate a new API key for the duplicated app - const newApiKey = await generateApiKey( - this.systemConfigDataService.environment.server.host, - newName - ); - - // Create a copy of the app with the new name and API key - // Using snake_case as expected by the API - const duplicatedApp = { - name: newName, - api_key: newApiKey, - description: `${app.description || ''} (copy)`, - is_active: app.isActive, - type: app.type, - role_id: app.roleId || null, - // Copy app location specific fields - url: app.url || null, - storage_service_id: app.storageServiceId || null, - storage_container: app.storageContainer || null, - path: app.path || null, - // Copy additional settings - requires_fullscreen: app.requiresFullscreen, - allow_fullscreen_toggle: app.allowFullscreenToggle, - toggle_location: app.toggleLocation, - }; - - // Wrap in resource array as expected by the API - const payload = { - resource: [duplicatedApp] - }; - - // Create the new app - this.appsService - .create(payload, { - snackbarSuccess: 'apps.alerts.duplicateSuccess', - fields: '*', - related: 'role_by_role_id' - }) - .pipe( - catchError(error => { - console.error('Failed to duplicate app:', error); - return throwError(() => error); + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the app with the new name + // Using snake_case as expected by the API + // Note: API key is generated server-side, not sent in payload + const duplicatedApp = { + name: newName, + description: `${app.description || ''} (copy)`, + is_active: app.isActive, + type: app.type, + role_id: app.roleId || null, + // Copy app location specific fields + url: app.url || null, + storage_service_id: app.storageServiceId || null, + storage_container: app.storageContainer || null, + path: app.path || null, + // Copy additional settings + requires_fullscreen: app.requiresFullscreen, + allow_fullscreen_toggle: app.allowFullscreenToggle, + toggle_location: app.toggleLocation, + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedApp], + }; + + // Create the new app + this.appsService + .create(payload, { + snackbarSuccess: 'apps.alerts.duplicateSuccess', + fields: '*', + related: 'role_by_role_id', }) - ) - .subscribe(() => { - this.refreshTable(); - }); - } + .pipe( + catchError(error => { + console.error('Failed to duplicate app:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); }); - }); - }); + }); } } diff --git a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts index 11a40260..d917c3c0 100644 --- a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts +++ b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts @@ -56,7 +56,7 @@ export class DfManageRolesTableComponent extends DfManageTableComponent dialog: MatDialog ) { super(router, activatedRoute, liveAnnouncer, translateService, dialog); - + // Add duplicate action const duplicateAction = { label: 'duplicate', @@ -67,7 +67,7 @@ export class DfManageRolesTableComponent extends DfManageTableComponent }, icon: faCopy, }; - + if (this.actions.additional) { // Insert duplicate action before delete action const deleteIndex = this.actions.additional.findIndex( @@ -132,7 +132,10 @@ export class DfManageRolesTableComponent extends DfManageTableComponent duplicateRole(row: RoleRow): void { // First, get the full role details with related data - this.roleService.get(row.id, { related: 'role_service_access_by_role_id,lookup_by_role_id' }) + this.roleService + .get(row.id, { + related: 'role_service_access_by_role_id,lookup_by_role_id', + }) .pipe( catchError(error => { console.error('Failed to fetch role details:', error); @@ -140,68 +143,81 @@ export class DfManageRolesTableComponent extends DfManageTableComponent }) ) .subscribe(roleData => { - // Get all existing role names for validation - this.roleService - .getAll>({ limit: 1000 }) - .subscribe(allRoles => { - const existingNames = allRoles.resource.map(r => r.name); - - const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { - width: '400px', - data: { - title: 'roles.duplicate.title', - message: 'roles.duplicate.message', - label: 'roles.duplicate.nameLabel', - originalName: roleData.name, - existingNames: existingNames, - }, - }); + // Get all existing role names for validation + this.roleService + .getAll>({ limit: 1000 }) + .subscribe(allRoles => { + const existingNames = allRoles.resource.map(r => r.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'roles.duplicate.title', + message: 'roles.duplicate.message', + label: 'roles.duplicate.nameLabel', + originalName: roleData.name, + existingNames: existingNames, + }, + }); - dialogRef.afterClosed().subscribe(newName => { - if (newName) { - // Create a copy of the role with all its configurations - const duplicatedRole = { - name: newName, - description: `${roleData.description || ''} (copy)`, - is_active: roleData.is_active, - // Copy service access permissions - role_service_access_by_role_id: roleData.role_service_access_by_role_id?.map((access: any) => ({ - service_id: access.service_id, - component: access.component, - verb_mask: access.verb_mask, - requestor_mask: access.requestor_mask, - filters: access.filters, - filter_op: access.filter_op, - })) || [], - // Copy lookup keys - lookup_by_role_id: roleData.lookup_by_role_id?.map((lookup: any) => ({ - name: lookup.name, - value: lookup.value, - private: lookup.private, - description: lookup.description, - })) || [], - }; - - // Wrap in resource array as expected by the API - const payload = { - resource: [duplicatedRole] - }; - - // Create the new role - this.roleService - .create(payload, { snackbarSuccess: 'roles.alerts.duplicateSuccess' }) - .pipe( - catchError(error => { - console.error('Failed to duplicate role:', error); - return throwError(() => error); + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the role with all its configurations + // Using camelCase as expected by the API + const duplicatedRole = { + name: newName, + description: `${roleData.description || ''} (copy)`, + isActive: roleData.is_active, + // Copy service access permissions with camelCase + roleServiceAccessByRoleId: + roleData.role_service_access_by_role_id?.map( + (access: any) => ({ + serviceId: access.service_id, + component: access.component, + verbMask: access.verb_mask, + requestorMask: access.requestor_mask, + filters: access.filters?.map((filter: any) => ({ + name: filter.name || filter.field, + operator: filter.operator, + value: filter.value, + })) || [], + filterOp: access.filter_op || 'AND', + }) + ) || [], + // Copy lookup keys with camelCase + lookupByRoleId: + roleData.lookup_by_role_id?.map((lookup: any) => ({ + name: lookup.name, + value: lookup.value, + private: lookup.private, + description: lookup.description, + })) || [], + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedRole], + }; + + // Create the new role + this.roleService + .create(payload, { + snackbarSuccess: 'roles.alerts.duplicateSuccess', + fields: '*', + related: 'role_service_access_by_role_id,lookup_by_role_id', }) - ) - .subscribe(() => { - this.refreshTable(); - }); - } + .pipe( + catchError(error => { + console.error('Failed to duplicate role:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); }); - }); - }); + }); } } diff --git a/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts b/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts index f144836e..21006627 100644 --- a/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts +++ b/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts @@ -63,7 +63,7 @@ export class DfManageServicesTableComponent extends DfManageTableComponent(row.id) + this.serviceService + .get(row.id) .pipe( catchError(error => { console.error('Failed to fetch service details:', error); @@ -200,56 +201,58 @@ export class DfManageServicesTableComponent extends DfManageTableComponent { - // Get all existing service names for validation - this.serviceService - .getAll>({ limit: 1000 }) - .subscribe(allServices => { - const existingNames = allServices.resource.map(s => s.name); - - const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { - width: '400px', - data: { - title: 'services.duplicate.title', - message: 'services.duplicate.message', - label: 'services.duplicate.nameLabel', - originalName: service.name, - existingNames: existingNames, - }, - }); + // Get all existing service names for validation + this.serviceService + .getAll>({ limit: 1000 }) + .subscribe(allServices => { + const existingNames = allServices.resource.map(s => s.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'services.duplicate.title', + message: 'services.duplicate.message', + label: 'services.duplicate.nameLabel', + originalName: service.name, + existingNames: existingNames, + }, + }); - dialogRef.afterClosed().subscribe(newName => { - if (newName) { - // Create a copy of the service with the new name, including all config - const duplicatedService = { - name: newName, - label: service.label || newName, - description: `${service.description || ''} (copy)`, - is_active: service.isActive, - type: service.type, - // Copy the entire config object which contains all service-specific settings - config: service.config ? { ...service.config } : {}, - }; - - // Wrap in resource array as expected by the API - const payload = { - resource: [duplicatedService] - }; - - // Create the new service - this.serviceService - .create(payload, { snackbarSuccess: 'services.alerts.duplicateSuccess' }) - .pipe( - catchError(error => { - console.error('Failed to duplicate service:', error); - return throwError(() => error); + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the service with the new name, including all config + const duplicatedService = { + name: newName, + label: service.label || newName, + description: `${service.description || ''} (copy)`, + is_active: service.isActive, + type: service.type, + // Copy the entire config object which contains all service-specific settings + config: service.config ? { ...service.config } : {}, + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedService], + }; + + // Create the new service + this.serviceService + .create(payload, { + snackbarSuccess: 'services.alerts.duplicateSuccess', }) - ) - .subscribe(() => { - this.refreshTable(); - }); - } + .pipe( + catchError(error => { + console.error('Failed to duplicate service:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); }); - }); - }); + }); } } diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html index 3c78eeba..b4fbcddb 100644 --- a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html @@ -3,12 +3,11 @@

{{ data.title | transloco }}

{{ data.message | transloco }}

{{ data.label | transloco }} - + cdkFocusInitial /> {{ 'validation.required' | transloco }} @@ -21,10 +20,7 @@

{{ data.title | transloco }}

-

color="primary"> {{ 'duplicate' | transloco }} - \ No newline at end of file + diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss index 54079260..3db0dee7 100644 --- a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss @@ -1,3 +1,3 @@ .full-width { width: 100%; -} \ No newline at end of file +} diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts index b588eef0..bf3847a2 100644 --- a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts @@ -1,11 +1,11 @@ import { Component, Inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { - FormControl, - ReactiveFormsModule, +import { + FormControl, + ReactiveFormsModule, Validators, AbstractControl, - ValidationErrors + ValidationErrors, } from '@angular/forms'; import { MAT_DIALOG_DATA, @@ -75,4 +75,4 @@ export class DfDuplicateDialogComponent { onCancel(): void { this.dialogRef.close(null); } -} \ No newline at end of file +} From c8cede9b3509601e6c3fbb7f4ac44138fcd45382 Mon Sep 17 00:00:00 2001 From: thekevinm Date: Tue, 5 Aug 2025 22:14:05 +0000 Subject: [PATCH 5/6] Duplication implementation --- .../df-manage-roles-table.component.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts index d917c3c0..d662d409 100644 --- a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts +++ b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts @@ -143,6 +143,7 @@ export class DfManageRolesTableComponent extends DfManageTableComponent }) ) .subscribe(roleData => { + console.log('Role data from API:', roleData); // Get all existing role names for validation this.roleService .getAll>({ limit: 1000 }) @@ -163,30 +164,31 @@ export class DfManageRolesTableComponent extends DfManageTableComponent dialogRef.afterClosed().subscribe(newName => { if (newName) { // Create a copy of the role with all its configurations - // Using camelCase as expected by the API + // Using snake_case for the API payload const duplicatedRole = { name: newName, description: `${roleData.description || ''} (copy)`, - isActive: roleData.is_active, - // Copy service access permissions with camelCase - roleServiceAccessByRoleId: - roleData.role_service_access_by_role_id?.map( + is_active: roleData.isActive || roleData.is_active, + // Copy service access permissions - check both camelCase and snake_case + role_service_access_by_role_id: + (roleData.roleServiceAccessByRoleId || roleData.role_service_access_by_role_id)?.map( (access: any) => ({ - serviceId: access.service_id, + service_id: access.serviceId || access.service_id, component: access.component, - verbMask: access.verb_mask, - requestorMask: access.requestor_mask, - filters: access.filters?.map((filter: any) => ({ - name: filter.name || filter.field, - operator: filter.operator, - value: filter.value, - })) || [], - filterOp: access.filter_op || 'AND', + verb_mask: access.verbMask || access.verb_mask, + requestor_mask: access.requestorMask || access.requestor_mask, + filters: + access.filters?.map((filter: any) => ({ + name: filter.name || filter.field, + operator: filter.operator, + value: filter.value, + })) || [], + filter_op: access.filterOp || access.filter_op || 'AND', }) ) || [], - // Copy lookup keys with camelCase - lookupByRoleId: - roleData.lookup_by_role_id?.map((lookup: any) => ({ + // Copy lookup keys - check both camelCase and snake_case + lookup_by_role_id: + (roleData.lookupByRoleId || roleData.lookup_by_role_id)?.map((lookup: any) => ({ name: lookup.name, value: lookup.value, private: lookup.private, @@ -199,6 +201,8 @@ export class DfManageRolesTableComponent extends DfManageTableComponent resource: [duplicatedRole], }; + console.log('Sending payload:', JSON.stringify(payload, null, 2)); + // Create the new role this.roleService .create(payload, { From b99bc2e9179066576cd1abf1a425513c04714f16 Mon Sep 17 00:00:00 2001 From: Kevin McGahey <36458555+thekevinm@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:16:39 -0700 Subject: [PATCH 6/6] Update df-manage-roles-table.component.ts removed console log --- .../adf-roles/df-manage-roles/df-manage-roles-table.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts index d662d409..4e3215cc 100644 --- a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts +++ b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts @@ -143,7 +143,6 @@ export class DfManageRolesTableComponent extends DfManageTableComponent }) ) .subscribe(roleData => { - console.log('Role data from API:', roleData); // Get all existing role names for validation this.roleService .getAll>({ limit: 1000 })