From 214200ce7a56bd6162086effb877ea9db8f9ada8 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 11:53:37 -0600 Subject: [PATCH 001/100] Refactor dot-usage-shell component for improved UI and structure - Replaced the usage header with a PrimeNG toolbar for better action handling. - Enhanced loading state with a more structured skeleton layout for site metrics, system configuration, and user activity. - Updated error handling to use PrimeNG card components for a cleaner presentation. - Refactored dashboard content layout to utilize flexbox for better responsiveness. - Adjusted SCSS styles to align with new component structure and improve overall styling consistency. This update enhances the user experience and maintains a modern design approach. --- .../dot-usage-shell.component.html | 221 ++++++++------- .../dot-usage-shell.component.scss | 253 +++--------------- .../dot-usage-shell.component.ts | 6 +- 3 files changed, 165 insertions(+), 315 deletions(-) diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html index efe31c566959..144095766885 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html @@ -1,116 +1,157 @@ -
- -
-
-

{{ 'usage.dashboard.title' | dm }}

-
- -
- -
+ +
+
+
+
- @if (loading() && !hasData()) { -
-
- @for (i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; track i) { - -
- - - -
-
- } + @if (loading()) { +
+ +
+

+ +

+
+ @for (i of [1, 2, 3]; track i) { + + +

+ +

+
+ +

+ +

+
+
+ } +
+
+ + +
+

+ +

+
+ @for (i of [1, 2, 3]; track i) { + + +

+ +

+
+ +

+ +

+
+
+ } +
+
+ + +
+

+ +

+
+ @for (i of [1, 2]; track i) { + + +

+ +

+
+ +

+ +

+
+
+ } +
} @if (error() && !loading()) { -
- - -
- -
- {{ 'usage.dashboard.error.title' | dm }} -

- @if ( - error() === 'usage.dashboard.error.requestFailed' && - errorStatus() - ) { - {{ - 'usage.dashboard.error.requestFailed' - | dm: [errorStatus()!.toString()] - }} - } @else { - {{ error() | dm }} - } -

-
- -
-
-
-
+ + +

+ + {{ 'usage.dashboard.error.title' | dm }} +

+
+ +

+ @if (error() === 'usage.dashboard.error.requestFailed' && errorStatus()) { + {{ 'usage.dashboard.error.requestFailed' | dm: [errorStatus()!.toString()] }} + } @else { + {{ error() | dm }} + } +

+ +
+
} - @if (hasData()) { -
+ @if (hasData() && !loading()) { +
@for (category of getCategories(); track category) { @if (getCategoryMetrics(category); as categoryMetrics) { -
-

{{ getCategoryTitleKey(category) | dm }}

-
+
+

{{ getCategoryTitleKey(category) | dm }}

+
@for ( metricEntry of categoryMetrics | keyvalue; track metricEntry.key ) { @if (metricEntry.value; as metricData) { -
-
- - {{ metricData.displayLabel | dm }} - - - @if ( - isI18nKey( - formatMetricValue(metricData.value) - ) - ) { - {{ - formatMetricValue(metricData.value) | dm - }} - } @else { - {{ formatMetricValue(metricData.value) }} - } - -
-
+ +

+ {{ metricData.displayLabel | dm }} +

+
+ +

+ @if ( + isI18nKey(formatMetricValue(metricData.value)) + ) { + {{ formatMetricValue(metricData.value) | dm }} + } @else { + {{ formatMetricValue(metricData.value) }} + } +

+
} } diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss index f740891e2392..fd054acf3512 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss @@ -1,236 +1,45 @@ -:host { - display: block; - width: 100%; - height: 100%; - overflow-y: auto; - background-color: #f4f3f4; -} - -.usage-dashboard { - padding: 1.5rem; - max-width: 1400px; - margin: 0 auto; - min-height: fit-content; - background: #f4f3f4; -} - -.usage-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 2rem; - padding-bottom: 1rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - - &__title { - h1 { - margin: 0 0 0.5rem 0; - color: #14151a; - font-size: 1.75rem; - font-weight: 500; - } +@use "variables" as *; - &__subtitle { - margin: 0 0 0.5rem 0; - color: rgba(0, 0, 0, 0.7); - font-size: 0.875rem; +:host { + ::ng-deep { + .p-card-body { + padding: $spacing-3; } - &__updated { - color: rgba(0, 0, 0, 0.5); - font-size: 0.75rem; + .p-card { + border: $field-border-size solid $color-palette-gray-400; + border-radius: $border-radius-xl; } } - - &__actions { - display: flex; - gap: 0.5rem; - } } -.usage-skeleton { - .usage-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - } +p-toolbar { + display: block; + margin-bottom: $spacing-4; } -.usage-error { - margin-bottom: 2rem; - - .error-content { - display: flex; - align-items: center; - gap: 1rem; - - i { - font-size: 1.5rem; - color: var(--color-danger-500); - } - - div { - flex: 1; - - strong { - display: block; - margin-bottom: 0.25rem; - } - - p { - margin: 0; - color: var(--color-text-light); - } - } - } +h2 { + margin: 0 0 $spacing-2 0; + font-size: $font-size-lg; + color: $color-palette-gray-900; + font-weight: $font-weight-bold; + letter-spacing: 0.01em; + line-height: 1.15; + text-transform: none; } -.usage-content { - .usage-section { - margin-bottom: 3rem; - - &:last-child { - margin-bottom: 0; - } - - .section-title { - margin: 0 0 1rem 0; - color: #14151a; - font-size: 1rem; - font-weight: 500; - display: flex; - align-items: center; - gap: 0.5rem; - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .metrics-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; - } - } +h3 { + font-size: $font-size-sm; + color: $color-palette-gray-600; + text-transform: uppercase; + font-weight: $font-weight-regular-bold; + letter-spacing: 0.05em; + margin: 0; } -.metric-card { - border-radius: 4px; - border: none; - overflow: hidden; - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - position: relative; - - // Remove all hover effects - &:hover { - transform: none !important; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important; - } - - // Override PrimeNG card hover effects - ::ng-deep .p-card { - border: none; - box-shadow: none; - background: white; - - &:hover { - transform: none !important; - box-shadow: none !important; - } - - .p-card-body { - padding: 0; - } - } - - &__content { - padding: 1.25rem; - display: flex; - flex-direction: column; - align-items: center; - gap: 0; - min-height: auto; - } - - &__data { - flex: 1; - min-width: 0; - - .metric-label { - display: block; - font-size: 0.875rem; - color: #14151a; - text-transform: none; - letter-spacing: 0; - font-weight: 400; - margin-bottom: 0.75rem; - text-align: center; - position: relative; - - &::after { - content: ""; - display: block; - width: 100%; - height: 1px; - background: rgba(0, 0, 0, 0.1); - margin-top: 0.75rem; - } - } - - .metric-value { - display: block; - font-size: 2.5rem; - font-weight: 500; - line-height: 1; - margin-top: 0.75rem; - margin-bottom: 0; - letter-spacing: -0.02em; - text-align: center; - color: var(--color-palette-secondary-500, #14151a); - - &--text { - font-size: 1.5rem; - font-weight: 500; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--color-palette-secondary-500, #14151a); - } - } - } -} - -// Responsive design -@media (max-width: 768px) { - .usage-dashboard { - padding: 1rem; - } - - .usage-header { - flex-direction: column; - gap: 1rem; - align-items: stretch; - - &__actions { - justify-content: flex-end; - } - } - - .usage-content .usage-section .metrics-row { - grid-template-columns: 1fr; - } - - .metric-card { - &__content { - padding: 1rem; - } - - &__data .metric-value { - font-size: 1.75rem; - - &--text { - font-size: 1.125rem; - } - } - } +// Metric value text +.metric-value { + margin: 0; + font-size: $font-size-xxxxl; + text-align: center; } diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts index 48ea5fcb7782..da0ba90b2fd5 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts @@ -5,7 +5,7 @@ import { ButtonModule } from 'primeng/button'; import { CardModule } from 'primeng/card'; import { MessagesModule } from 'primeng/messages'; import { SkeletonModule } from 'primeng/skeleton'; -import { TooltipModule } from 'primeng/tooltip'; +import { ToolbarModule } from 'primeng/toolbar'; import { DotMessagePipe } from '@dotcms/ui'; @@ -19,8 +19,8 @@ import { DotUsageService, MetricData } from '../services/dot-usage.service'; CardModule, MessagesModule, SkeletonModule, - TooltipModule, - DotMessagePipe + DotMessagePipe, + ToolbarModule ], templateUrl: './dot-usage-shell.component.html', styleUrl: './dot-usage-shell.component.scss', From f3201ce3699ec90ae4ab23d7bb56e2ed87e734f4 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:03:07 -0600 Subject: [PATCH 002/100] Update dot-usage-shell component for improved skeleton loading and styling - Adjusted skeleton component heights for better visual consistency. - Added margin utility class to paragraph elements within skeleton templates. - Enhanced SCSS styles for skeleton display and h3 elements to improve layout and readability. These changes aim to refine the user interface and enhance the loading experience in the dot-usage-shell component. --- .../dot-usage-shell.component.html | 15 +++++++++------ .../dot-usage-shell.component.scss | 5 +++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html index 144095766885..977cc9166c0b 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html @@ -20,7 +20,7 @@

- +

@for (i of [1, 2, 3]; track i) { @@ -31,7 +31,7 @@

-

+

@@ -54,7 +54,7 @@

-

+

@@ -77,7 +77,7 @@

-

+

@@ -100,7 +100,9 @@

@if (error() === 'usage.dashboard.error.requestFailed' && errorStatus()) { - {{ 'usage.dashboard.error.requestFailed' | dm: [errorStatus()!.toString()] }} + {{ + 'usage.dashboard.error.requestFailed' | dm: [errorStatus()!.toString()] + }} } @else { {{ error() | dm }} } @@ -139,7 +141,8 @@

-

diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss index fd054acf3512..c9e3fcf93323 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss @@ -13,6 +13,10 @@ } } +p-skeleton { + display: block; +} + p-toolbar { display: block; margin-bottom: $spacing-4; @@ -35,6 +39,7 @@ h3 { font-weight: $font-weight-regular-bold; letter-spacing: 0.05em; margin: 0; + line-height: 1rem; } // Metric value text From 81bd166cc7b18f3a44b6258a1d4ab464d88f82de Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:09:37 -0600 Subject: [PATCH 003/100] Enhance dot-usage-shell component with last updated timestamp --- .../lib/dot-usage-shell/dot-usage-shell.component.html | 10 +++++++++- .../lib/dot-usage-shell/dot-usage-shell.component.ts | 4 +++- .../main/webapp/WEB-INF/messages/Language.properties | 1 + 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html index 977cc9166c0b..720518089f80 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html @@ -1,5 +1,13 @@ -

+
+ @if (lastUpdated()) { + + {{ 'usage.dashboard.lastUpdated' | dm }}: + {{ lastUpdated()! | date: 'short' }} + + } +
+
this.summary() !== null); + readonly lastUpdated = signal(null); ngOnInit(): void { this.loadData(); } @@ -45,6 +46,7 @@ export class DotUsageShellComponent implements OnInit { this.usageService.getSummary().subscribe({ next: () => { // Data is automatically updated via signals + this.lastUpdated.set(new Date()); }, error: (error) => { console.error('Failed to load usage data:', error); diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 2afa578d8ba3..3017ed9c29e0 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6378,3 +6378,4 @@ usage.dashboard.error.serviceUnavailable=Service unavailable. Please try again l usage.dashboard.error.requestFailed=Request failed with status {0}. usage.dashboard.error.generic=Failed to load usage data. Please check your connection and try again. usage.dashboard.value.notAvailable=N/A +usage.dashboard.lastUpdated=Last Updated From 66503e6a957c32038a49d2953f81156a95e45635 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:19:46 -0600 Subject: [PATCH 004/100] Refactor dot-usage component tests to use structured metrics format --- .../dot-usage-shell.component.spec.ts | 79 ++++++++++--------- .../lib/services/dot-usage.service.spec.ts | 66 ++++++++-------- 2 files changed, 76 insertions(+), 69 deletions(-) diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts index 6004eff9b17f..51be26cec7f3 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts @@ -13,31 +13,35 @@ describe('DotUsageShellComponent', () => { let usageService: DotUsageService; const mockSummary: UsageSummary = { - contentMetrics: { - totalContent: 1500, - contentTypes: 25, - recentlyEdited: 230, - contentTypesWithWorkflows: 18, - lastContentEdited: '2024-01-15' - }, - siteMetrics: { - totalSites: 5, - activeSites: 4, - templates: 12, - siteAliases: 8 - }, - userMetrics: { - activeUsers: 45, - totalUsers: 60, - recentLogins: 12, - lastLogin: '2024-01-15T10:30:00Z' - }, - systemMetrics: { - languages: 3, - workflowSchemes: 2, - workflowSteps: 14, - liveContainers: 9, - builderTemplates: 6 + metrics: { + content: { + COUNT_CONTENT: { + name: 'COUNT_CONTENT', + value: 1500, + displayLabel: 'usage.metric.COUNT_CONTENT' + } + }, + site: { + COUNT_OF_SITES: { + name: 'COUNT_OF_SITES', + value: 5, + displayLabel: 'usage.metric.COUNT_OF_SITES' + } + }, + user: { + COUNT_OF_USERS: { + name: 'COUNT_OF_USERS', + value: 60, + displayLabel: 'usage.metric.COUNT_OF_USERS' + } + }, + system: { + COUNT_LANGUAGES: { + name: 'COUNT_LANGUAGES', + value: 3, + displayLabel: 'usage.metric.COUNT_LANGUAGES' + } + } }, lastUpdated: '2024-01-15T15:30:00Z' }; @@ -46,6 +50,7 @@ describe('DotUsageShellComponent', () => { summary: signal(null), loading: signal(false), error: signal(null), + errorStatus: signal(null), getSummary: jest.fn().mockReturnValue(of(mockSummary)), refresh: jest.fn().mockReturnValue(of(mockSummary)), reset: jest.fn() @@ -78,7 +83,6 @@ describe('DotUsageShellComponent', () => { spectator.detectChanges(); - expect(spectator.query('.usage-skeleton')).toBeTruthy(); expect(spectator.query('p-skeleton')).toBeTruthy(); }); @@ -89,8 +93,6 @@ describe('DotUsageShellComponent', () => { spectator.detectChanges(); - expect(spectator.query('.usage-error')).toBeTruthy(); - expect(spectator.query('p-messages')).toBeTruthy(); expect(spectator.query('[data-testid="retry-button"]')).toBeTruthy(); }); @@ -101,15 +103,18 @@ describe('DotUsageShellComponent', () => { spectator.detectChanges(); - expect(spectator.query('.usage-content')).toBeTruthy(); - expect(spectator.query('[data-testid="total-sites-card"]')).toBeTruthy(); - expect(spectator.query('[data-testid="total-content-card"]')).toBeTruthy(); - expect(spectator.query('[data-testid="total-users-card"]')).toBeTruthy(); - expect(spectator.query('[data-testid="languages-card"]')).toBeTruthy(); + expect(spectator.query('[data-testid="site-COUNT_OF_SITES-card"]')).toBeTruthy(); + expect(spectator.query('[data-testid="content-COUNT_CONTENT-card"]')).toBeTruthy(); + expect(spectator.query('[data-testid="user-COUNT_OF_USERS-card"]')).toBeTruthy(); + expect(spectator.query('[data-testid="system-COUNT_LANGUAGES-card"]')).toBeTruthy(); }); it('should handle refresh button click', () => { - spectator.click('[data-testid="refresh-button"]'); + jest.clearAllMocks(); + const refreshButton = spectator.query('[data-testid="refresh-button"]'); + expect(refreshButton).toBeTruthy(); + + spectator.dispatchFakeEvent(refreshButton, 'onClick'); expect(usageService.getSummary).toHaveBeenCalled(); }); @@ -124,7 +129,7 @@ describe('DotUsageShellComponent', () => { // Reset the mocks to clear previous calls jest.clearAllMocks(); - spectator.click('[data-testid="retry-button"]'); + spectator.dispatchFakeEvent(retryButton, 'onClick'); expect(usageService.reset).toHaveBeenCalled(); expect(usageService.getSummary).toHaveBeenCalled(); @@ -150,10 +155,10 @@ describe('DotUsageShellComponent', () => { usageService.summary.set(mockSummary); spectator.detectChanges(); - const totalSitesCard = spectator.query('[data-testid="total-sites-card"]'); + const totalSitesCard = spectator.query('[data-testid="site-COUNT_OF_SITES-card"]'); expect(totalSitesCard?.textContent).toContain('5'); - const totalContentCard = spectator.query('[data-testid="total-content-card"]'); + const totalContentCard = spectator.query('[data-testid="content-COUNT_CONTENT-card"]'); expect(totalContentCard?.textContent).toContain('1.5K'); }); }); diff --git a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts b/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts index 9b02617db888..e0b9135664f6 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts @@ -8,31 +8,35 @@ describe('DotUsageService', () => { let httpMock: HttpTestingController; const mockSummary: UsageSummary = { - contentMetrics: { - totalContent: 1500, - contentTypes: 25, - recentlyEdited: 230, - contentTypesWithWorkflows: 18, - lastContentEdited: '2024-01-15' - }, - siteMetrics: { - totalSites: 5, - activeSites: 4, - templates: 12, - siteAliases: 8 - }, - userMetrics: { - activeUsers: 45, - totalUsers: 60, - recentLogins: 12, - lastLogin: '2024-01-15T10:30:00Z' - }, - systemMetrics: { - languages: 3, - workflowSchemes: 2, - workflowSteps: 14, - liveContainers: 9, - builderTemplates: 6 + metrics: { + content: { + COUNT_CONTENT: { + name: 'COUNT_CONTENT', + value: 1500, + displayLabel: 'usage.metric.COUNT_CONTENT' + } + }, + site: { + COUNT_OF_SITES: { + name: 'COUNT_OF_SITES', + value: 5, + displayLabel: 'usage.metric.COUNT_OF_SITES' + } + }, + user: { + COUNT_OF_USERS: { + name: 'COUNT_OF_USERS', + value: 60, + displayLabel: 'usage.metric.COUNT_OF_USERS' + } + }, + system: { + COUNT_LANGUAGES: { + name: 'COUNT_LANGUAGES', + value: 3, + displayLabel: 'usage.metric.COUNT_LANGUAGES' + } + } }, lastUpdated: '2024-01-15T15:30:00Z' }; @@ -75,7 +79,7 @@ describe('DotUsageService', () => { service.getSummary().subscribe({ next: () => fail('Should have failed'), error: (_error) => { - expect(service.error()).toBe('You are not authorized to view this data.'); + expect(service.error()).toBe('usage.dashboard.error.unauthorized'); expect(service.loading()).toBe(false); expect(service.summary()).toBeNull(); done(); @@ -90,7 +94,7 @@ describe('DotUsageService', () => { service.getSummary().subscribe({ next: () => fail('Should have failed'), error: (_error) => { - expect(service.error()).toBe('Server error occurred. Please try again later.'); + expect(service.error()).toBe('usage.dashboard.error.serverError'); expect(service.loading()).toBe(false); done(); } @@ -104,7 +108,7 @@ describe('DotUsageService', () => { service.getSummary().subscribe({ next: () => fail('Should have failed'), error: (_error) => { - expect(service.error()).toBe('You do not have permission to access usage data.'); + expect(service.error()).toBe('usage.dashboard.error.forbidden'); expect(service.loading()).toBe(false); done(); } @@ -118,7 +122,7 @@ describe('DotUsageService', () => { service.getSummary().subscribe({ next: () => fail('Should have failed'), error: (_error) => { - expect(service.error()).toBe('Request timed out. Please try again.'); + expect(service.error()).toBe('usage.dashboard.error.timeout'); expect(service.loading()).toBe(false); done(); } @@ -132,9 +136,7 @@ describe('DotUsageService', () => { service.getSummary().subscribe({ next: () => fail('Should have failed'), error: (_error) => { - expect(service.error()).toBe( - 'Failed to load usage data. Please check your connection and try again.' - ); + expect(service.error()).toBe('usage.dashboard.error.generic'); expect(service.loading()).toBe(false); done(); } From 879ce379807cc956ceac65f7ba3c4ae43380b91b Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:23:11 -0600 Subject: [PATCH 005/100] Refactor dot-usage service and shell component tests to utilize new HttpClient providers --- .../lib/dot-usage-shell/dot-usage-shell.component.spec.ts | 6 +++--- .../dot-usage/src/lib/services/dot-usage.service.spec.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts index 51be26cec7f3..126974ee343b 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts @@ -1,7 +1,8 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; import { of, throwError } from 'rxjs'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { signal } from '@angular/core'; import { DotUsageShellComponent } from './dot-usage-shell.component'; @@ -58,8 +59,7 @@ describe('DotUsageShellComponent', () => { const createComponent = createComponentFactory({ component: DotUsageShellComponent, - imports: [HttpClientTestingModule], - providers: [{ provide: DotUsageService, useValue: mockService }] + providers: [provideHttpClient(), provideHttpClientTesting(), { provide: DotUsageService, useValue: mockService }] }); beforeEach(() => { diff --git a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts b/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts index e0b9135664f6..f2edda14993e 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts @@ -1,4 +1,5 @@ -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { DotUsageService, UsageApiResponse, UsageSummary } from './dot-usage.service'; @@ -43,8 +44,7 @@ describe('DotUsageService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [DotUsageService] + providers: [provideHttpClient(), provideHttpClientTesting(), DotUsageService] }); service = TestBed.inject(DotUsageService); From 67e26d1ef51f34e81a8bbcb0900d608815957d86 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:37:31 -0600 Subject: [PATCH 006/100] Add dot-usage service and tests for usage summary functionality - Introduced DotUsageService to fetch usage summary metrics from the backend API. - Implemented error handling for various HTTP status codes. - Added unit tests for DotUsageService to validate summary retrieval and error handling. - Updated dot-usage-shell component to manage loading and error states using signals. - Refactored component tests to utilize the new service structure. These changes enhance the functionality and reliability of the dot-usage feature, ensuring accurate data retrieval and user-friendly error messages. --- core-web/libs/data-access/src/index.ts | 1 + .../lib/dot-usage}/dot-usage.service.spec.ts | 131 ++++++------------ .../src/lib/dot-usage}/dot-usage.service.ts | 43 +----- core-web/libs/portlets/dot-usage/src/index.ts | 1 - .../dot-usage-shell.component.spec.ts | 47 ++++--- .../dot-usage-shell.component.ts | 31 +++-- 6 files changed, 92 insertions(+), 162 deletions(-) rename core-web/libs/{portlets/dot-usage/src/lib/services => data-access/src/lib/dot-usage}/dot-usage.service.spec.ts (54%) rename core-web/libs/{portlets/dot-usage/src/lib/services => data-access/src/lib/dot-usage}/dot-usage.service.ts (74%) diff --git a/core-web/libs/data-access/src/index.ts b/core-web/libs/data-access/src/index.ts index 81068c43b25e..e6a8db3fea65 100644 --- a/core-web/libs/data-access/src/index.ts +++ b/core-web/libs/data-access/src/index.ts @@ -70,3 +70,4 @@ export * from './lib/push-publish/push-publish.service'; export * from './lib/dot-page-contenttype/dot-page-contenttype.service'; export * from './lib/dot-favorite-contenttype/dot-favorite-contenttype.service'; export * from './lib/dot-content-drive/dot-content-drive.service'; +export * from './lib/dot-usage/dot-usage.service'; diff --git a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.spec.ts similarity index 54% rename from core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts rename to core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.spec.ts index f2edda14993e..6f9b1a05245d 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.spec.ts @@ -64,9 +64,6 @@ describe('DotUsageService', () => { service.getSummary().subscribe((summary) => { expect(summary).toEqual(mockSummary); - expect(service.summary()).toEqual(mockSummary); - expect(service.loading()).toBe(false); - expect(service.error()).toBeNull(); done(); }); @@ -78,10 +75,8 @@ describe('DotUsageService', () => { it('should handle HTTP errors', (done) => { service.getSummary().subscribe({ next: () => fail('Should have failed'), - error: (_error) => { - expect(service.error()).toBe('usage.dashboard.error.unauthorized'); - expect(service.loading()).toBe(false); - expect(service.summary()).toBeNull(); + error: (error) => { + expect(error.status).toBe(401); done(); } }); @@ -93,9 +88,8 @@ describe('DotUsageService', () => { it('should handle server errors', (done) => { service.getSummary().subscribe({ next: () => fail('Should have failed'), - error: (_error) => { - expect(service.error()).toBe('usage.dashboard.error.serverError'); - expect(service.loading()).toBe(false); + error: (error) => { + expect(error.status).toBe(500); done(); } }); @@ -104,101 +98,57 @@ describe('DotUsageService', () => { req.flush('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }); }); - it('should handle forbidden errors', (done) => { - service.getSummary().subscribe({ - next: () => fail('Should have failed'), - error: (_error) => { - expect(service.error()).toBe('usage.dashboard.error.forbidden'); - expect(service.loading()).toBe(false); - done(); - } - }); - - const req = httpMock.expectOne('/api/v1/usage/summary'); - req.flush('Forbidden', { status: 403, statusText: 'Forbidden' }); + it('should get error message for 401', () => { + const error = { status: 401 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.unauthorized'); }); - it('should handle network timeout errors', (done) => { - service.getSummary().subscribe({ - next: () => fail('Should have failed'), - error: (_error) => { - expect(service.error()).toBe('usage.dashboard.error.timeout'); - expect(service.loading()).toBe(false); - done(); - } - }); - - const req = httpMock.expectOne('/api/v1/usage/summary'); - req.flush('Request Timeout', { status: 408, statusText: 'Request Timeout' }); + it('should get error message for 403', () => { + const error = { status: 403 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.forbidden'); }); - it('should handle unknown errors', (done) => { - service.getSummary().subscribe({ - next: () => fail('Should have failed'), - error: (_error) => { - expect(service.error()).toBe('usage.dashboard.error.generic'); - expect(service.loading()).toBe(false); - done(); - } - }); - - const req = httpMock.expectOne('/api/v1/usage/summary'); - req.error(new ProgressEvent('error')); + it('should get error message for 404', () => { + const error = { status: 404 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.notFound'); }); - it('should handle custom error messages', (done) => { - const customErrorMessage = 'Custom service error message'; - - service.getSummary().subscribe({ - next: () => fail('Should have failed'), - error: (_error) => { - expect(service.error()).toBe(customErrorMessage); - expect(service.loading()).toBe(false); - done(); - } - }); - - const req = httpMock.expectOne('/api/v1/usage/summary'); - req.flush({ message: customErrorMessage }, { status: 400, statusText: 'Bad Request' }); + it('should get error message for 408', () => { + const error = { status: 408 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.timeout'); }); - it('should maintain loading state during request', () => { - expect(service.loading()).toBe(false); - - service.getSummary().subscribe(); - expect(service.loading()).toBe(true); - - const req = httpMock.expectOne('/api/v1/usage/summary'); - req.flush({ entity: mockSummary }); - - expect(service.loading()).toBe(false); + it('should get error message for 500', () => { + const error = { status: 500 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.serverError'); }); - it('should clear error state when making new request', () => { - // Set initial error state - service.error.set('Previous error'); - expect(service.error()).toBe('Previous error'); - - service.getSummary().subscribe(); - - expect(service.error()).toBeNull(); + it('should get error message for 502', () => { + const error = { status: 502 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.badGateway'); + }); - const req = httpMock.expectOne('/api/v1/usage/summary'); - req.flush({ entity: mockSummary }); + it('should get error message for 503', () => { + const error = { status: 503 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.serviceUnavailable'); }); - it('should reset state', () => { - // Set some state first - service.summary.set(mockSummary); - service.loading.set(true); - service.error.set('Some error'); + it('should get error message for unknown status', () => { + const error = { status: 418 } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.requestFailed'); + }); - // Reset - service.reset(); + it('should get error message from error.error.message', () => { + const error = { + error: { message: 'Custom error message' }, + status: 400 + } as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('Custom error message'); + }); - expect(service.summary()).toBeNull(); - expect(service.loading()).toBe(false); - expect(service.error()).toBeNull(); + it('should get generic error message when no status', () => { + const error = {} as HttpErrorResponse; + expect(service.getErrorMessage(error)).toBe('usage.dashboard.error.generic'); }); it('should refresh data', (done) => { @@ -245,3 +195,4 @@ describe('DotUsageService', () => { req.flush({ entity: invalidResponse }); }); }); + diff --git a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.ts b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.ts similarity index 74% rename from core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.ts rename to core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.ts index cb9e922d4cd6..9a2f8b619ac4 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/services/dot-usage.service.ts +++ b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.ts @@ -1,9 +1,9 @@ import { Observable } from 'rxjs'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { Injectable, inject, signal } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; -import { catchError, map, tap } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; /** * Metric metadata structure containing name, value, and display label. @@ -68,15 +68,6 @@ export interface UsageErrorResponse { readonly statusText?: string; } -/** - * Service state interface for reactive state management - */ -export interface UsageServiceState { - readonly summary: UsageSummary | null; - readonly loading: boolean; - readonly error: string | null; -} - @Injectable({ providedIn: 'root' }) @@ -84,30 +75,13 @@ export class DotUsageService { #BASE_URL = '/api/v1/usage'; #http = inject(HttpClient); - // Reactive state - readonly summary = signal(null); - readonly loading = signal(false); - readonly error = signal(null); - readonly errorStatus = signal(null); - /** * Fetches usage summary from the backend API */ getSummary(): Observable { - this.loading.set(true); - this.error.set(null); - return this.#http.get(`${this.#BASE_URL}/summary`).pipe( map((response) => response.entity), - tap((summary) => { - this.summary.set(summary); - this.loading.set(false); - }), catchError((error) => { - const errorMessage = this.getErrorMessage(error); - this.error.set(errorMessage); - this.errorStatus.set(error.status || null); - this.loading.set(false); console.error('Failed to fetch usage summary:', error); throw error; }) @@ -121,21 +95,11 @@ export class DotUsageService { return this.getSummary(); } - /** - * Resets the service state - */ - reset(): void { - this.summary.set(null); - this.loading.set(false); - this.error.set(null); - this.errorStatus.set(null); - } - /** * Extracts user-friendly error message i18n key from HTTP error * Returns i18n keys that should be translated using the dm pipe in the component */ - private getErrorMessage(error: HttpErrorResponse | UsageErrorResponse): string { + getErrorMessage(error: HttpErrorResponse | UsageErrorResponse): string { if (error.error?.message) { return error.error.message; } @@ -165,3 +129,4 @@ export class DotUsageService { return 'usage.dashboard.error.generic'; } } + diff --git a/core-web/libs/portlets/dot-usage/src/index.ts b/core-web/libs/portlets/dot-usage/src/index.ts index da97bcb36ae8..2de988cac487 100644 --- a/core-web/libs/portlets/dot-usage/src/index.ts +++ b/core-web/libs/portlets/dot-usage/src/index.ts @@ -1,3 +1,2 @@ export * from './lib/dot-usage-shell/dot-usage-shell.component'; -export * from './lib/services/dot-usage.service'; export * from './lib.routes'; diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts index 126974ee343b..b4aee674ff92 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts @@ -3,11 +3,10 @@ import { of, throwError } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { signal } from '@angular/core'; -import { DotUsageShellComponent } from './dot-usage-shell.component'; +import { DotUsageService, UsageSummary } from '@dotcms/data-access'; -import { DotUsageService, UsageSummary } from '../services/dot-usage.service'; +import { DotUsageShellComponent } from './dot-usage-shell.component'; describe('DotUsageShellComponent', () => { let spectator: Spectator; @@ -48,13 +47,9 @@ describe('DotUsageShellComponent', () => { }; const mockService = { - summary: signal(null), - loading: signal(false), - error: signal(null), - errorStatus: signal(null), getSummary: jest.fn().mockReturnValue(of(mockSummary)), refresh: jest.fn().mockReturnValue(of(mockSummary)), - reset: jest.fn() + getErrorMessage: jest.fn().mockReturnValue('usage.dashboard.error.generic') }; const createComponent = createComponentFactory({ @@ -77,9 +72,9 @@ describe('DotUsageShellComponent', () => { }); it('should display loading state', () => { - // Mock loading state - usageService.loading.set(true); - usageService.summary.set(null); + // Set component loading state + spectator.component.loading.set(true); + spectator.component.summary.set(null); spectator.detectChanges(); @@ -88,8 +83,8 @@ describe('DotUsageShellComponent', () => { it('should display error state', () => { const errorMessage = 'Failed to load data'; - usageService.loading.set(false); - usageService.error.set(errorMessage); + spectator.component.loading.set(false); + spectator.component.error.set(errorMessage); spectator.detectChanges(); @@ -97,9 +92,9 @@ describe('DotUsageShellComponent', () => { }); it('should display data when loaded', () => { - usageService.loading.set(false); - usageService.summary.set(mockSummary); - usageService.error.set(null); + spectator.component.loading.set(false); + spectator.component.summary.set(mockSummary); + spectator.component.error.set(null); spectator.detectChanges(); @@ -119,8 +114,8 @@ describe('DotUsageShellComponent', () => { }); it('should handle retry button click', () => { - usageService.loading.set(false); - usageService.error.set('Some error'); + spectator.component.loading.set(false); + spectator.component.error.set('Some error'); spectator.detectChanges(); const retryButton = spectator.query('[data-testid="retry-button"]'); @@ -131,7 +126,8 @@ describe('DotUsageShellComponent', () => { spectator.dispatchFakeEvent(retryButton, 'onClick'); - expect(usageService.reset).toHaveBeenCalled(); + expect(spectator.component.summary()).toBeNull(); + expect(spectator.component.error()).toBeNull(); expect(usageService.getSummary).toHaveBeenCalled(); }); @@ -143,16 +139,23 @@ describe('DotUsageShellComponent', () => { it('should handle service errors gracefully', () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - usageService.getSummary = jest.fn().mockReturnValue(throwError('Network error')); + const httpError: { status: number; statusText: string } = { + status: 500, + statusText: 'Internal Server Error' + }; + usageService.getSummary = jest.fn().mockReturnValue(throwError(() => httpError)); + usageService.getErrorMessage = jest.fn().mockReturnValue('usage.dashboard.error.serverError'); spectator.component.loadData(); - expect(errorSpy).toHaveBeenCalledWith('Failed to load usage data:', 'Network error'); + expect(errorSpy).toHaveBeenCalledWith('Failed to load usage data:', httpError); + expect(spectator.component.error()).toBe('usage.dashboard.error.serverError'); + expect(spectator.component.loading()).toBe(false); errorSpy.mockRestore(); }); it('should show correct metric values', () => { - usageService.summary.set(mockSummary); + spectator.component.summary.set(mockSummary); spectator.detectChanges(); const totalSitesCard = spectator.query('[data-testid="site-COUNT_OF_SITES-card"]'); diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts index d192d9a7c900..14ffab62326d 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts @@ -7,10 +7,9 @@ import { MessagesModule } from 'primeng/messages'; import { SkeletonModule } from 'primeng/skeleton'; import { ToolbarModule } from 'primeng/toolbar'; +import { DotUsageService, MetricData, UsageSummary } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotUsageService, MetricData } from '../services/dot-usage.service'; - @Component({ selector: 'lib-dot-usage-shell', imports: [ @@ -29,26 +28,36 @@ import { DotUsageService, MetricData } from '../services/dot-usage.service'; export class DotUsageShellComponent implements OnInit { private readonly usageService = inject(DotUsageService); - // Reactive state from service - readonly summary = this.usageService.summary; - readonly loading = this.usageService.loading; - readonly error = this.usageService.error; - readonly errorStatus = this.usageService.errorStatus; + // UI state managed by component + readonly summary = signal(null); + readonly loading = signal(false); + readonly error = signal(null); + readonly errorStatus = signal(null); // Computed values for display readonly hasData = computed(() => this.summary() !== null); readonly lastUpdated = signal(null); + ngOnInit(): void { this.loadData(); } loadData(): void { + this.loading.set(true); + this.error.set(null); + this.errorStatus.set(null); + this.usageService.getSummary().subscribe({ - next: () => { - // Data is automatically updated via signals + next: (summary) => { + this.summary.set(summary); + this.loading.set(false); this.lastUpdated.set(new Date()); }, error: (error) => { + const errorMessage = this.usageService.getErrorMessage(error); + this.error.set(errorMessage); + this.errorStatus.set(error.status || null); + this.loading.set(false); console.error('Failed to load usage data:', error); } }); @@ -59,7 +68,9 @@ export class DotUsageShellComponent implements OnInit { } onRetry(): void { - this.usageService.reset(); + this.summary.set(null); + this.error.set(null); + this.errorStatus.set(null); this.loadData(); } From 317f1ad3e6eb5dfd04cc4fcc25b699a5e2874115 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:40:17 -0600 Subject: [PATCH 007/100] Enhance DotUsageShellComponent tests to improve error handling and loading state management --- .../dot-usage-shell.component.spec.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts index b4aee674ff92..6802258bea4b 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.spec.ts @@ -1,5 +1,5 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; -import { of, throwError } from 'rxjs'; +import { of, throwError, Subject } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -116,6 +116,7 @@ describe('DotUsageShellComponent', () => { it('should handle retry button click', () => { spectator.component.loading.set(false); spectator.component.error.set('Some error'); + spectator.component.summary.set(mockSummary); spectator.detectChanges(); const retryButton = spectator.query('[data-testid="retry-button"]'); @@ -124,11 +125,24 @@ describe('DotUsageShellComponent', () => { // Reset the mocks to clear previous calls jest.clearAllMocks(); + // Use a Subject to control when the observable emits + const summarySubject = new Subject(); + usageService.getSummary = jest.fn().mockReturnValue(summarySubject.asObservable()); + spectator.dispatchFakeEvent(retryButton, 'onClick'); + // Check that reset happened synchronously (before observable completes) + // The onRetry method resets state first, then calls loadData expect(spectator.component.summary()).toBeNull(); expect(spectator.component.error()).toBeNull(); + expect(spectator.component.errorStatus()).toBeNull(); expect(usageService.getSummary).toHaveBeenCalled(); + // After loadData is called, loading should be true + expect(spectator.component.loading()).toBe(true); + + // Complete the observable + summarySubject.next(mockSummary); + summarySubject.complete(); }); it('should format numbers correctly', () => { @@ -148,7 +162,9 @@ describe('DotUsageShellComponent', () => { spectator.component.loadData(); - expect(errorSpy).toHaveBeenCalledWith('Failed to load usage data:', httpError); + // The error passed to console.error will be the error object, not the function + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy.mock.calls[0][0]).toBe('Failed to load usage data:'); expect(spectator.component.error()).toBe('usage.dashboard.error.serverError'); expect(spectator.component.loading()).toBe(false); errorSpy.mockRestore(); From daa6ae10eec8c567767cac9cfa4df405d6d56ebf Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:46:51 -0600 Subject: [PATCH 008/100] Add informational message to DotUsageShellComponent for analytics updates --- .../dot-usage-shell.component.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html index 720518089f80..3b7fa9d3e175 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html @@ -1,3 +1,18 @@ + + +
+
+ + + {{ 'analytics.feature.state' | dm }} + {{ 'development' | dm }} + +
+ +
+
+
+
@if (lastUpdated()) { From 625dde30a0cc87c898e26524ba3bd57d80988d66 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:14:00 -0600 Subject: [PATCH 009/100] Enhance DotUsageShellComponent with accessibility improvements and error handling --- .../lib/dot-usage/dot-usage.service.spec.ts | 8 ++++- .../dot-usage-shell.component.html | 14 ++++++-- .../dot-usage-shell.component.scss | 12 +++++++ .../dot-usage-shell.component.ts | 35 +++++++++++-------- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.spec.ts b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.spec.ts index 6f9b1a05245d..e09be43759cd 100644 --- a/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.spec.ts @@ -1,4 +1,4 @@ -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, HttpErrorResponse } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; @@ -73,10 +73,13 @@ describe('DotUsageService', () => { }); it('should handle HTTP errors', (done) => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + service.getSummary().subscribe({ next: () => fail('Should have failed'), error: (error) => { expect(error.status).toBe(401); + errorSpy.mockRestore(); done(); } }); @@ -86,10 +89,13 @@ describe('DotUsageService', () => { }); it('should handle server errors', (done) => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + service.getSummary().subscribe({ next: () => fail('Should have failed'), error: (error) => { expect(error.status).toBe(500); + errorSpy.mockRestore(); done(); } }); diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html index 3b7fa9d3e175..f18dce97ad39 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.html @@ -39,7 +39,14 @@
@if (loading()) { -
+
+ + {{ 'usage.dashboard.loading' | dm }} +

@@ -116,7 +123,10 @@

- + {{ 'usage.dashboard.error.title' | dm }}

diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss index c9e3fcf93323..f75f4e3c703b 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.scss @@ -48,3 +48,15 @@ h3 { font-size: $font-size-xxxxl; text-align: center; } + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts index 14ffab62326d..efb9ec4b95bd 100644 --- a/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts +++ b/core-web/libs/portlets/dot-usage/src/lib/dot-usage-shell/dot-usage-shell.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, OnInit, computed, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnInit, computed, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ButtonModule } from 'primeng/button'; import { CardModule } from 'primeng/card'; @@ -27,6 +28,7 @@ import { DotMessagePipe } from '@dotcms/ui'; }) export class DotUsageShellComponent implements OnInit { private readonly usageService = inject(DotUsageService); + private readonly destroyRef = inject(DestroyRef); // UI state managed by component readonly summary = signal(null); @@ -47,20 +49,23 @@ export class DotUsageShellComponent implements OnInit { this.error.set(null); this.errorStatus.set(null); - this.usageService.getSummary().subscribe({ - next: (summary) => { - this.summary.set(summary); - this.loading.set(false); - this.lastUpdated.set(new Date()); - }, - error: (error) => { - const errorMessage = this.usageService.getErrorMessage(error); - this.error.set(errorMessage); - this.errorStatus.set(error.status || null); - this.loading.set(false); - console.error('Failed to load usage data:', error); - } - }); + this.usageService + .getSummary() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (summary) => { + this.summary.set(summary); + this.loading.set(false); + this.lastUpdated.set(new Date()); + }, + error: (error) => { + const errorMessage = this.usageService.getErrorMessage(error); + this.error.set(errorMessage); + this.errorStatus.set(error.status || null); + this.loading.set(false); + console.error('Failed to load usage data:', error); + } + }); } onRefresh(): void { From c90d04bf01febd8dcd3ca8927a56581f7049b137 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:50:31 -0600 Subject: [PATCH 010/100] add layout tree --- .../dot-uve-palette.component.html | 20 +++++++ .../dot-uve-palette.component.ts | 58 ++++++++++++++++++- .../src/lib/store/features/editor/models.ts | 3 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html index 77763ae88925..211c74c8c6c5 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html @@ -40,6 +40,26 @@ [pagePath]="$pagePath()" /> } + + +
+ +
+
+ @if ($activeTab() === 3) { +
+ @if ($layoutTree().length > 0) { + + } @else { +
+ Tree nodes: {{ $layoutTree().length }} + Page response: {{ uveStore.pageAPIResponse() ? 'available' : 'null' }} + Has rows: {{ uveStore.pageAPIResponse()?.layout?.body?.rows?.length ?? 0 }} +
+ } +
+ } +
@if ($showStyleEditorTab()) { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index 0a15dab0aae0..4a77b073420a 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -1,7 +1,9 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, EventEmitter, inject, input, Output } from '@angular/core'; +import { TreeNode } from 'primeng/api'; import { TabViewChangeEvent, TabViewModule } from 'primeng/tabview'; import { TooltipModule } from 'primeng/tooltip'; +import { TreeModule } from 'primeng/tree'; import { DEFAULT_VARIANT_ID } from '@dotcms/dotcms-models'; import { StyleEditorFormSchema } from '@dotcms/uve'; @@ -10,6 +12,7 @@ import { DotUvePaletteListComponent } from './components/dot-uve-palette-list/do import { DotUveStyleEditorFormComponent } from './components/dot-uve-style-editor-form/dot-uve-style-editor-form.component'; import { DotUVEPaletteListTypes } from './models'; +import { UVEStore } from '../../../store/dot-uve.store'; import { UVE_PALETTE_TABS } from '../../../store/features/editor/models'; /** @@ -25,7 +28,8 @@ import { UVE_PALETTE_TABS } from '../../../store/features/editor/models'; TabViewModule, DotUvePaletteListComponent, TooltipModule, - DotUveStyleEditorFormComponent + DotUveStyleEditorFormComponent, + TreeModule ], templateUrl: './dot-uve-palette.component.html', styleUrl: './dot-uve-palette.component.scss', @@ -67,9 +71,59 @@ export class DotUvePaletteComponent { */ @Output() onTabChange = new EventEmitter(); + protected readonly uveStore = inject(UVEStore); protected readonly TABS_MAP = UVE_PALETTE_TABS; protected readonly DotUVEPaletteListTypes = DotUVEPaletteListTypes; + /** + * Computed signal that transforms the page layout structure into TreeNode format + * for the PrimeNG Tree component. Structure: rows > columns > containers + */ + readonly $layoutTree = computed(() => { + const pageResponse = this.uveStore.pageAPIResponse(); + + if (!pageResponse?.layout?.body?.rows) { + return []; + } + + const rows = pageResponse.layout.body.rows; + const containers = pageResponse.containers || {}; + + const treeNodes: TreeNode[] = rows.map((row, rowIndex) => { + const columnNodes: TreeNode[] = (row.columns || []).map((column, columnIndex) => { + const containerNodes: TreeNode[] = (column.containers || []).map((container, containerIndex) => { + const containerData = containers[container.identifier]; + const containerLabel = containerData?.container?.friendlyName || + containerData?.container?.title || + container.identifier || + `Container ${containerIndex + 1}`; + + return { + key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}`, + label: containerLabel, + data: container + }; + }); + + return { + key: `row-${rowIndex}-column-${columnIndex}`, + label: `Column ${columnIndex + 1}`, + expanded: containerNodes.length > 0, + children: containerNodes.length > 0 ? containerNodes : undefined + }; + }); + + return { + key: `row-${rowIndex}`, + label: `Row ${rowIndex + 1}`, + expanded: columnNodes.length > 0, + children: columnNodes.length > 0 ? columnNodes : undefined + }; + }); + + return treeNodes; + }); + /** * Called whenever the tab changes, either by user interaction or via the `activeIndex` property. * diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts index 02ec1b090886..9d6a37c2968d 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts @@ -117,5 +117,6 @@ export enum UVE_PALETTE_TABS { CONTENT_TYPES = 0, WIDGETS = 1, FAVORITES = 2, - STYLE_EDITOR = 3 + STYLE_EDITOR = 3, + LAYERS = 4 } From 1c31dfb144bddcd07326de20ffe5c8cd9719f4ab Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:49:58 -0600 Subject: [PATCH 011/100] Implement zoom and pan functionality in EditEmaEditor component --- .../edit-ema-editor.component.html | 150 ++++++--- .../edit-ema-editor.component.scss | 100 +++++- .../edit-ema-editor.component.ts | 314 +++++++++++++++++- 3 files changed, 498 insertions(+), 66 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index 8216d60747b2..4be572c1babd 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -29,65 +29,105 @@
-
- @if (uveStore.status() === UVE_STATUS.ERROR) { - - } - +
+
+ +
+ {{ zoomLabel() }} +
+ + +
+
+ +
+
+
+ @if (uveStore.status() === UVE_STATUS.ERROR) { + + } + - @if ($editorProps().progressBar) { - - } - @if ($showContentletControls()) { - - } - @if (!$toggleLockOptions()?.showOverlay && dropzone) { - - } + @if ($editorProps().progressBar) { + + } + @if ($showContentletControls()) { + + } + @if (!$toggleLockOptions()?.showOverlay && dropzone) { + + } - @if ($toggleLockOptions()?.showOverlay) { - - } + @if ($toggleLockOptions()?.showOverlay) { + + } +
+
+
+ +
@@ -113,7 +153,7 @@
{{ uveStore.$areaContentType() }} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss index d9b8eebc24d2..4c4754d9b011 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss @@ -54,33 +54,113 @@ dot-results-seo-tool { } .editor-content { - padding: $spacing-4 $spacing-3; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + padding: $spacing-4 0; gap: $spacing-1; overflow: auto; + position: relative; + height: 100%; + min-height: 0; grid-column: 2 / -1; } +.canvas-viewport { + position: relative; + width: 100%; + min-height: 100%; + // IMPORTANT: + // Do NOT use flex + justify-content:center here. + // When the canvas is wider than the scroll container, centering can introduce a negative left offset + // that becomes unreachable because scrollLeft cannot be negative (canvas appears "cut" on the left). + display: block; +} + +.canvas-row { + display: flex; + align-items: flex-start; + width: fit-content; + margin: 0 auto; // center when it fits; when it overflows, scroll controls visibility +} + +.canvas-gutter { + flex: 0 0 $spacing-3; + width: $spacing-3; +} + +.zoom-controls { + position: absolute; + top: $spacing-3; + right: $spacing-3; + z-index: 5; + display: flex; + align-items: center; + gap: $spacing-1; + padding: $spacing-2; + background: rgba(255, 255, 255, 0.9); + border: 1px solid $color-palette-gray-200; + border-radius: $border-radius-lg; + backdrop-filter: blur(6px); +} + +.zoom-controls__btn { + appearance: none; + border: 1px solid $color-palette-gray-300; + background: $white; + color: $color-palette-gray-900; + border-radius: $border-radius-md; + padding: 0 $spacing-2; + height: 2rem; + font-weight: $font-weight-medium-bold; + cursor: pointer; + + &:hover { + background: $color-palette-gray-100; + } +} + +.zoom-controls__btn--reset { + padding: 0 $spacing-3; +} + +.zoom-controls__label { + min-width: 3.5rem; + text-align: center; + font-weight: $font-weight-bold; + color: $color-palette-bluegray-700; +} + .editor-content-preview { - padding: $spacing-4; + padding: $spacing-4 0; +} + +.canvas-outer { + position: relative; + display: block; + user-select: none; + margin: 0 auto; +} + +.canvas-inner { + position: relative; } .iframe-wrapper { position: relative; overflow: hidden; - flex-grow: 1; margin: 0 auto; border: solid 1px $color-palette-gray-300; - height: 100%; - width: 100%; - transition: all 0.3s ease-in-out; + width: 1520px !important; + min-width: 1520px; + height: auto; + transition: border 0.3s ease-in-out; iframe { border: none; + display: block; + width: 100%; + // Height is controlled from TS to match the iframe document height (no iframe scrolling). + height: auto; + min-height: 1px; } dot-uve-lock-overlay { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index 648a50e9f3e6..a72cf601fd13 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -128,7 +128,7 @@ import { DotUvePageVersionNotFoundComponent, DotUveContentletToolsComponent, DotUveLockOverlayComponent, - DotUvePaletteComponent + DotUvePaletteComponent, ], providers: [ DotCopyContentModalService, @@ -143,6 +143,8 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit @ViewChild('iframe') iframe!: ElementRef; @ViewChild('blockSidebar') blockSidebar: DotBlockEditorSidebarComponent; @ViewChild('customDragImage') customDragImage: ElementRef; + @ViewChild('zoomContainer') zoomContainer!: ElementRef; + @ViewChild('editorContent') editorContent!: ElementRef; protected readonly uveStore = inject(UVEStore); private readonly dotMessageService = inject(DotMessageService); @@ -164,6 +166,16 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit readonly #dotAlertConfirmService = inject(DotAlertConfirmService); #iframeResizeObserver: ResizeObserver | null = null; + // Zoom and pan state + readonly $zoomLevel = signal(1); + readonly $isZoomMode = signal(false); // Ctrl/Cmd pressed + #zoomModeResetTimeout: ReturnType | null = null; + #gestureStartZoom = 1; + readonly $iframeDocHeight = signal(0); + #didSetInitialScroll = false; + #iframeContentResizeObserver: ResizeObserver | null = null; + #iframeMutationObserver: MutationObserver | null = null; + readonly host = '*'; readonly $ogTags: WritableSignal = signal(undefined); readonly $editorProps = this.uveStore.$editorProps; @@ -185,6 +197,26 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit return this.$paletteOpen() ? PALETTE_CLASSES.OPEN : PALETTE_CLASSES.CLOSED; }); + readonly $canvasOuterStyles = computed(() => { + const zoom = this.$zoomLevel(); + const height = this.$iframeDocHeight() || 800; + return { + width: `${1520 * zoom}px`, + height: `${height * zoom}px` + }; + }); + + readonly $canvasInnerStyles = computed(() => { + const zoom = this.$zoomLevel(); + const height = this.$iframeDocHeight() || 800; + return { + width: `1520px`, + height: `${height}px`, + transform: `scale(${zoom})`, + transformOrigin: 'top left' + }; + }); + get contentWindow(): Window | null { return this.iframe?.nativeElement?.contentWindow || null; } @@ -228,6 +260,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit { name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS }, this.host ); + }); ngOnInit(): void { @@ -240,6 +273,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit ngAfterViewInit(): void { this.#setupContentletAreaReset(); + this.#setupZoomAndPan(); } /** @@ -483,6 +517,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.#insertPageContent(); this.#setSeoData(); + this.#setupIframeAutoHeight(); if (this.uveStore.state() === EDITOR_STATE.INLINE_EDITING) { this.inlineEditingService.initEditor(); @@ -575,6 +610,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit ngOnDestroy(): void { this.#iframeResizeObserver?.disconnect(); this.#iframeResizeObserver = null; + this.#teardownIframeAutoHeight(); if (this.uveStore.isTraditionalPage()) { this.uveStore.setIsClientReady(true); } @@ -1572,10 +1608,286 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.resetContentletArea(); } + #teardownIframeAutoHeight(): void { + this.#iframeContentResizeObserver?.disconnect(); + this.#iframeContentResizeObserver = null; + this.#iframeMutationObserver?.disconnect(); + this.#iframeMutationObserver = null; + } + + #setupIframeAutoHeight(): void { + // Traditional pages are written into the iframe via doc.write(), so they are same-origin + // and we can measure scrollHeight safely. + const iframeEl = this.iframe?.nativeElement; + + if (!iframeEl) { + return; + } + + // Clean previous observers (reloads, navigation, etc.) + this.#teardownIframeAutoHeight(); + + const doc = iframeEl.contentDocument; + const win = iframeEl.contentWindow; + + if (!doc || !win) { + return; + } + + const updateHeight = () => { + const body = doc.body; + const root = doc.documentElement; + + if (!body || !root) { + return; + } + + // Take the max between body/root to handle different doc modes. + const height = Math.max(body.scrollHeight, root.scrollHeight); + + // Set explicit px height so the iframe itself never scrolls. + iframeEl.style.height = `${height}px`; + this.$iframeDocHeight.set(height); + this.#clampScrollWithinBounds(); + + // First time we know the real dimensions, start at top-left so header is visible. + if (!this.#didSetInitialScroll) { + this.#didSetInitialScroll = true; + this.#scrollToTopLeft(); + } + }; + + // Initial sizing after layout settles. + requestAnimationFrame(() => { + updateHeight(); + requestAnimationFrame(updateHeight); + }); + + // Keep height synced as the iframe content changes. + if (typeof ResizeObserver !== 'undefined') { + this.#iframeContentResizeObserver = new ResizeObserver(() => updateHeight()); + // Observe both body and root (some layouts expand root, some body). + this.#iframeContentResizeObserver.observe(doc.body); + this.#iframeContentResizeObserver.observe(doc.documentElement); + } + + if (typeof MutationObserver !== 'undefined') { + this.#iframeMutationObserver = new MutationObserver(() => updateHeight()); + this.#iframeMutationObserver.observe(doc.documentElement, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } + + // Fonts/images can load async and change height. + fromEvent(win, 'load') + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(() => updateHeight()); + } + + #setupZoomAndPan(): void { + const zoomContainer = this.zoomContainer?.nativeElement; + const editorContent = this.editorContent?.nativeElement; + + if (!zoomContainer || !editorContent) { + return; + } + + type GestureLikeEvent = Event & { + scale?: number; + clientX: number; + clientY: number; + preventDefault: () => void; + }; + + // Track zoom modifier keys (optional, to indicate "zoom mode" + support Safari gestures). + fromEvent(this.window, 'keydown') + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((event) => { + if (event.key === 'Control' || event.code.startsWith('Control')) { + this.$isZoomMode.set(true); + } + if (event.key === 'Meta' || event.code.startsWith('Meta')) { + this.$isZoomMode.set(true); + } + }); + + fromEvent(this.window, 'keyup') + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((event) => { + if (event.key === 'Control' || event.code.startsWith('Control')) { + this.$isZoomMode.set(false); + } + if (event.key === 'Meta' || event.code.startsWith('Meta')) { + this.$isZoomMode.set(false); + } + }); + + const applyZoomFromWheel = (event: WheelEvent) => { + // Trackpad pinch zoom (and Ctrl/Cmd+wheel) sets ctrlKey/metaKey. + if (!event.ctrlKey && !event.metaKey) { + return; + } + + // Only hijack zoom if the pointer is inside the editor content region. + const rect = editorContent.getBoundingClientRect(); + const insideEditor = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!insideEditor) { + return; + } + + // Critical: stops the browser from zooming the whole viewport. + event.preventDefault(); + + this.$isZoomMode.set(true); + if (this.#zoomModeResetTimeout) { + clearTimeout(this.#zoomModeResetTimeout); + } + this.#zoomModeResetTimeout = setTimeout(() => this.$isZoomMode.set(false), 150); + + const delta = event.deltaY > 0 ? -0.1 : 0.1; + const newZoom = Math.max(0.1, Math.min(3, this.$zoomLevel() + delta)); + this.$zoomLevel.set(newZoom); + this.#clampScrollWithinBounds(); + }; + + // Zoom with mouse wheel on the canvas container + fromEvent(zoomContainer, 'wheel', { passive: false }) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(applyZoomFromWheel); + + // Zoom with mouse wheel on the whole editor content area + fromEvent(editorContent, 'wheel', { passive: false }) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(applyZoomFromWheel); + + // Trackpad pinch can still trigger browser zoom if the event originates from the iframe. + // Capture wheel at window-level and preventDefault when pointer is inside editor. + fromEvent(this.window, 'wheel', { passive: false, capture: true } as AddEventListenerOptions) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(applyZoomFromWheel); + + // Safari trackpad pinch uses non-standard GestureEvents (best-effort support). + fromEvent( + this.window, + 'gesturestart', + { passive: false, capture: true } as AddEventListenerOptions + ) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((event) => { + const rect = editorContent.getBoundingClientRect(); + const insideEditor = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!insideEditor) { + return; + } + + event.preventDefault(); + this.$isZoomMode.set(true); + this.#gestureStartZoom = this.$zoomLevel(); + }); + + fromEvent( + this.window, + 'gesturechange', + { passive: false, capture: true } as AddEventListenerOptions + ) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((event) => { + const rect = editorContent.getBoundingClientRect(); + const insideEditor = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!insideEditor) { + return; + } + + event.preventDefault(); + const scale = typeof event.scale === 'number' ? event.scale : 1; + const newZoom = Math.max(0.1, Math.min(3, this.#gestureStartZoom * scale)); + this.$zoomLevel.set(newZoom); + }); + + fromEvent( + this.window, + 'gestureend', + { passive: false, capture: true } as AddEventListenerOptions + ) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((event) => { + event.preventDefault(); + this.$isZoomMode.set(false); + }); + + // No panning: scrolling on editorContent is the navigation mechanism. + } + protected handleSelectContent(contentlet: ContentletPayload): void { this.uveStore.setActiveContentlet(contentlet); } + zoomIn(): void { + this.$zoomLevel.set(Math.max(0.1, Math.min(3, this.$zoomLevel() + 0.1))); + this.#clampScrollWithinBounds(); + } + + zoomOut(): void { + this.$zoomLevel.set(Math.max(0.1, Math.min(3, this.$zoomLevel() - 0.1))); + this.#clampScrollWithinBounds(); + } + + resetView(): void { + this.$zoomLevel.set(1); + this.#scrollToTopLeft(); + } + + zoomLabel(): string { + return `${Math.round(this.$zoomLevel() * 100)}%`; + } + + #scrollToTopLeft(): void { + const el = this.editorContent?.nativeElement; + if (!el) { + return; + } + + // Let layout apply widths/heights first. + requestAnimationFrame(() => { + el.scrollLeft = 0; + el.scrollTop = 0; + }); + } + + #clampScrollWithinBounds(): void { + const el = this.editorContent?.nativeElement; + if (!el) { + return; + } + + requestAnimationFrame(() => { + // Use real scroll bounds so gutters/padding inside the content are included. + const maxLeft = Math.max(0, el.scrollWidth - el.clientWidth); + const maxTop = Math.max(0, el.scrollHeight - el.clientHeight); + + el.scrollLeft = Math.min(Math.max(0, el.scrollLeft), maxLeft); + el.scrollTop = Math.min(Math.max(0, el.scrollTop), maxTop); + }); + } + /** * Applies the custom drag preview used when the drag originates from the * contentlet controls (identified via `data-drag-origin="contentlet-controls"`). From 162a5453f7509d4bc8da49150fbe57658923fffb Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:55:41 -0600 Subject: [PATCH 012/100] Refactor EditEmaEditor component to improve message handling and iframe height management --- .../edit-ema-editor.component.ts | 82 ++++++++++++++++++- examples/nextjs/src/utils/getDotCMSPage.js | 21 ++--- examples/nextjs/src/views/Page.js | 36 ++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index a72cf601fd13..566b18d1805a 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -266,9 +266,9 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit ngOnInit(): void { this.handleDragEvents(); - fromEvent(this.window, 'message') + fromEvent(this.window, 'message') .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(({ data }: MessageEvent) => this.handlePostMessage(data)); + .subscribe((event) => this.#handleWindowMessage(event)); } ngAfterViewInit(): void { @@ -1146,6 +1146,73 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit actionToExecute?.(payload); } + #handleWindowMessage(event: MessageEvent): void { + // 1) Cross-origin iframe height bridge (e.g. Next.js at localhost:3000) + // Expected shape: + // { name: 'dotcms:iframeHeight', payload: { height: number } } + // or { type: 'dotcms:iframeHeight', height: number } + if (this.#maybeHandleIframeHeightMessage(event)) { + return; + } + + // 2) UVE messages + const data = event.data; + if (this.#isUvePostMessage(data)) { + this.handlePostMessage(data); + } + } + + #isUvePostMessage(data: unknown): data is PostMessage { + return ( + !!data && + typeof data === 'object' && + 'action' in data && + // payload can be any shape depending on action + true + ); + } + + #maybeHandleIframeHeightMessage(event: MessageEvent): boolean { + const iframeEl = this.iframe?.nativeElement; + if (!iframeEl?.contentWindow) { + return false; + } + + // Only accept messages from the current iframe window. + if (event.source !== iframeEl.contentWindow) { + return false; + } + + const data = event.data as unknown; + if (!data || typeof data !== 'object') { + return false; + } + + const record = data as Record; + const isNamed = + record['name'] === 'dotcms:iframeHeight' && typeof record['payload'] === 'object'; + const isTyped = record['type'] === 'dotcms:iframeHeight'; + + let height: number | null = null; + if (isNamed) { + const payload = record['payload'] as Record; + height = typeof payload['height'] === 'number' ? payload['height'] : null; + } else if (isTyped) { + height = typeof record['height'] === 'number' ? (record['height'] as number) : null; + } + + if (!height || !Number.isFinite(height) || height <= 0) { + return false; + } + + // Apply height so iframe never scrolls; also update our layout sizing for zoom/scroll. + iframeEl.style.height = `${Math.ceil(height)}px`; + this.$iframeDocHeight.set(Math.ceil(height)); + this.#clampScrollWithinBounds(); + + return true; + } + /** * Notify the user to reload the iframe * @@ -1627,8 +1694,15 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit // Clean previous observers (reloads, navigation, etc.) this.#teardownIframeAutoHeight(); - const doc = iframeEl.contentDocument; - const win = iframeEl.contentWindow; + let doc: Document | null = null; + let win: Window | null = null; + try { + doc = iframeEl.contentDocument; + win = iframeEl.contentWindow; + } catch { + // Cross-origin; height must be provided via postMessage. + return; + } if (!doc || !win) { return; diff --git a/examples/nextjs/src/utils/getDotCMSPage.js b/examples/nextjs/src/utils/getDotCMSPage.js index 321b2a283bb9..9a0a999cffad 100644 --- a/examples/nextjs/src/utils/getDotCMSPage.js +++ b/examples/nextjs/src/utils/getDotCMSPage.js @@ -9,16 +9,17 @@ import { export const getDotCMSPage = cache(async (path) => { try { - const pageData = await dotCMSClient.page.get(path, { - graphql: { - content: { - blogs: blogQuery, - destinations: destinationQuery, - navigation: navigationQuery, - }, - fragments: [fragmentNav], - }, - }); + // const pageData = await dotCMSClient.page.get(path, { + // graphql: { + // content: { + // blogs: blogQuery, + // destinations: destinationQuery, + // navigation: navigationQuery, + // }, + // fragments: [fragmentNav], + // }, + // }); + const pageData = await dotCMSClient.page.get(path); return pageData; } catch (e) { console.error("ERROR FETCHING PAGE: ", e.message); diff --git a/examples/nextjs/src/views/Page.js b/examples/nextjs/src/views/Page.js index 6097eb56c23f..6b0a633bfd0e 100644 --- a/examples/nextjs/src/views/Page.js +++ b/examples/nextjs/src/views/Page.js @@ -6,6 +6,41 @@ import { pageComponents } from "@/components/content-types"; import Footer from "@/components/footer/Footer"; import Header from "@/components/header/Header"; +import { useEffect } from 'react'; + +export function IframeHeightBridge() { + useEffect(() => { + const send = () => { + const height = Math.max( + document.body?.scrollHeight ?? 0, + document.documentElement?.scrollHeight ?? 0 + ); + + window.parent.postMessage( + { name: 'dotcms:iframeHeight', payload: { height } }, + '*' + ); + }; + + send(); + + const ro = new ResizeObserver(send); + ro.observe(document.documentElement); + if (document.body) ro.observe(document.body); + + window.addEventListener('load', send); + window.addEventListener('resize', send); + + return () => { + ro.disconnect(); + window.removeEventListener('load', send); + window.removeEventListener('resize', send); + }; + }, []); + + return null; +} + export function Page({ pageContent }) { const { pageAsset, content = {} } = useEditableDotCMSPage(pageContent); const navigation = content.navigation; @@ -25,6 +60,7 @@ export function Page({ pageContent }) { {pageAsset?.layout.footer &&
} +
); } From 44538533af980239ba24c9f3bf24171502fadb28 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:58:38 -0600 Subject: [PATCH 013/100] Refactor EditEmaEditor component: rename zoom and pan method, update SCSS styles for layout consistency --- .../src/lib/edit-ema-editor/edit-ema-editor.component.scss | 2 -- .../src/lib/edit-ema-editor/edit-ema-editor.component.ts | 4 ++-- .../portlet/src/lib/store/features/editor/withEditor.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss index 4c4754d9b011..19f4baa3bb8f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.scss @@ -55,7 +55,6 @@ dot-results-seo-tool { .editor-content { padding: $spacing-4 0; - gap: $spacing-1; overflow: auto; position: relative; height: 100%; @@ -137,7 +136,6 @@ dot-results-seo-tool { position: relative; display: block; user-select: none; - margin: 0 auto; } .canvas-inner { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index 566b18d1805a..2f995288bf0f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -273,7 +273,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit ngAfterViewInit(): void { this.#setupContentletAreaReset(); - this.#setupZoomAndPan(); + this.#setupZoomInteractions(); } /** @@ -1761,7 +1761,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit .subscribe(() => updateHeight()); } - #setupZoomAndPan(): void { + #setupZoomInteractions(): void { const zoomContainer = this.zoomContainer?.nativeElement; const editorContent = this.editorContent?.nativeElement; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts index 59a46e3fbbfe..4651af878e0e 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts @@ -104,7 +104,7 @@ export function withEditor() { return !!contentletPosition && canEditPage && isIdle; }), - $styleSchema: computed(() => { + $styleSchema: computed(() => { const contentlet = store.activeContentlet(); const styleSchemas = store.styleSchemas(); const contentSchema = styleSchemas.find( From 8615a440c30a0845ecdcb1d11902d50a199bbff7 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:50:14 -0600 Subject: [PATCH 014/100] Refactor EditEmaEditor component: rename zoom and pan method, update SCSS styles for layout consistency --- .../dot-uve-iframe.component.html | 13 + .../dot-uve-iframe.component.scss | 13 + .../dot-uve-iframe.component.ts | 278 ++++ .../dot-uve-zoom-controls.component.html | 30 + .../dot-uve-zoom-controls.component.scss | 44 + .../dot-uve-zoom-controls.component.ts | 33 + .../edit-ema-editor.component.html | 48 +- .../edit-ema-editor.component.ts | 1114 +++-------------- .../dot-uve-actions-handler.service.ts | 302 +++++ .../dot-uve-bridge/dot-uve-bridge.service.ts | 115 ++ .../dot-uve-drag-drop.service.ts | 174 +++ .../dot-uve-zoom/dot-uve-zoom.service.ts | 190 +++ 12 files changed, 1379 insertions(+), 975 deletions(-) create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.html create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.scss create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.ts create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.html create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.scss create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.ts create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.html new file mode 100644 index 000000000000..4f71f7159796 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.html @@ -0,0 +1,13 @@ + + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.scss b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.scss new file mode 100644 index 000000000000..ea15c3526947 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; + position: relative; +} + +iframe { + border: none; + display: block; + width: 100%; + height: auto; + min-height: 1px; +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.ts new file mode 100644 index 000000000000..49738d294b7a --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.ts @@ -0,0 +1,278 @@ +import { fromEvent } from 'rxjs'; + +import { NgStyle } from '@angular/common'; +import { + Component, + ElementRef, + EventEmitter, + inject, + Input, + OnDestroy, + Output, + ViewChild, + signal, + DestroyRef, + effect +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { toObservable } from '@angular/core/rxjs-interop'; + +import { DotMessageService, DotSeoMetaTagsService, DotSeoMetaTagsUtilService } from '@dotcms/data-access'; +import { SeoMetaTags } from '@dotcms/dotcms-models'; +import { SafeUrlPipe } from '@dotcms/ui'; + +import { InlineEditService } from '../../../services/inline-edit/inline-edit.service'; +import { UVEStore } from '../../../store/dot-uve.store'; +import { SDK_EDITOR_SCRIPT_SOURCE } from '../../../utils'; + +@Component({ + selector: 'dot-uve-iframe', + standalone: true, + templateUrl: './dot-uve-iframe.component.html', + styleUrls: ['./dot-uve-iframe.component.scss'], + imports: [NgStyle, SafeUrlPipe] +}) +export class DotUveIframeComponent implements OnDestroy { + @ViewChild('iframe') iframe!: ElementRef; + + @Input() src!: string | null; + @Input() title!: string; + @Input() pointerEvents!: string | null; + @Input() opacity!: number | null; + @Input() host = '*'; + + @Output() load = new EventEmitter(); + @Output() internalNav = new EventEmitter(); + @Output() inlineEditing = new EventEmitter(); + @Output() iframeDocHeightChange = new EventEmitter(); + + protected readonly uveStore = inject(UVEStore); + private readonly dotMessageService = inject(DotMessageService); + private readonly dotSeoMetaTagsService = inject(DotSeoMetaTagsService); + private readonly dotSeoMetaTagsUtilService = inject(DotSeoMetaTagsUtilService); + private readonly inlineEditingService = inject(InlineEditService); + private readonly destroyRef = inject(DestroyRef); + + #iframeContentResizeObserver: ResizeObserver | null = null; + #iframeMutationObserver: MutationObserver | null = null; + #didSetInitialScroll = false; + + readonly $iframeDocHeight = signal(0); + + readonly $pageRender = this.uveStore.$pageRender; + readonly $enableInlineEdit = this.uveStore.$enableInlineEdit; + readonly $isTraditionalPageEffect = effect(() => { + const isTraditional = this.uveStore.isTraditionalPage(); + const pageRender = this.$pageRender(); + const enableInlineEdit = this.$enableInlineEdit(); + + if (isTraditional && pageRender && this.iframe?.nativeElement?.contentDocument) { + this.insertPageContent(pageRender, enableInlineEdit); + } + }); + + get contentWindow(): Window | null { + return this.iframe?.nativeElement?.contentWindow || null; + } + + get iframeElement(): HTMLIFrameElement | null { + return this.iframe?.nativeElement || null; + } + + onIframeLoad(): void { + if (!this.uveStore.isTraditionalPage()) { + this.load.emit(); + return; + } + + this.insertPageContent(this.$pageRender(), this.$enableInlineEdit()); + this.setSeoData(); + this.setupIframeAutoHeight(); + this.load.emit(); + } + + private insertPageContent(pageRender: string, enableInlineEdit: boolean): void { + const iframeElement = this.iframe?.nativeElement; + + if (!iframeElement) { + return; + } + + const doc = iframeElement.contentDocument; + const newDoc = this.injectCodeToVTL(pageRender); + + if (!doc) { + return; + } + + doc.open(); + doc.write(newDoc); + doc.close(); + + this.handleInlineScripts(enableInlineEdit); + } + + private injectCodeToVTL(rendered: string): string { + const fileWithScript = this.addEditorPageScript(rendered); + return this.addCustomStyles(fileWithScript); + } + + private addEditorPageScript(rendered = ''): string { + const scriptString = ``; + const bodyExists = rendered.includes(''); + + if (!bodyExists) { + return rendered + scriptString; + } + + return rendered.replace('', scriptString + ''); + } + + private addCustomStyles(rendered = ''): string { + const styles = ` + `; + + const headExists = rendered.includes(''); + + if (!headExists) { + return rendered + styles; + } + + return rendered.replace('', styles + ''); + } + + private handleInlineScripts(enableInlineEdit: boolean): void { + const win = this.contentWindow; + + if (!win) { + return; + } + + fromEvent(win, 'click') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((e: MouseEvent) => { + this.internalNav.emit(e); + this.inlineEditing.emit(e); + }); + + if (enableInlineEdit) { + this.inlineEditingService.injectInlineEdit(this.iframe); + } else { + this.inlineEditingService.removeInlineEdit(this.iframe); + } + } + + private setSeoData(): void { + const iframeElement = this.iframe?.nativeElement; + + if (!iframeElement) { + return; + } + + const doc = iframeElement.contentDocument; + + if (!doc) { + return; + } + + this.dotSeoMetaTagsService.getMetaTagsResults(doc).subscribe((results) => { + const ogTags = this.dotSeoMetaTagsUtilService.getMetaTags(doc); + this.uveStore.setOgTags(ogTags); + this.uveStore.setOGTagResults(results); + }); + } + + private setupIframeAutoHeight(): void { + const iframeEl = this.iframe?.nativeElement; + + if (!iframeEl) { + return; + } + + this.teardownIframeAutoHeight(); + + let doc: Document | null = null; + let win: Window | null = null; + try { + doc = iframeEl.contentDocument; + win = iframeEl.contentWindow; + } catch { + // Cross-origin; height must be provided via postMessage. + return; + } + + if (!doc || !win) { + return; + } + + const updateHeight = () => { + const body = doc.body; + const root = doc.documentElement; + + if (!body || !root) { + return; + } + + const height = Math.max(body.scrollHeight, root.scrollHeight); + iframeEl.style.height = `${height}px`; + this.$iframeDocHeight.set(height); + this.iframeDocHeightChange.emit(height); + }; + + requestAnimationFrame(() => { + updateHeight(); + requestAnimationFrame(updateHeight); + }); + + if (typeof ResizeObserver !== 'undefined') { + this.#iframeContentResizeObserver = new ResizeObserver(() => updateHeight()); + this.#iframeContentResizeObserver.observe(doc.body); + this.#iframeContentResizeObserver.observe(doc.documentElement); + } + + if (typeof MutationObserver !== 'undefined') { + this.#iframeMutationObserver = new MutationObserver(() => updateHeight()); + this.#iframeMutationObserver.observe(doc.documentElement, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } + + fromEvent(win, 'load') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => updateHeight()); + } + + private teardownIframeAutoHeight(): void { + this.#iframeContentResizeObserver?.disconnect(); + this.#iframeContentResizeObserver = null; + this.#iframeMutationObserver?.disconnect(); + this.#iframeMutationObserver = null; + } + + ngOnDestroy(): void { + this.teardownIframeAutoHeight(); + } +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.html new file mode 100644 index 000000000000..3bb152c679ab --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.html @@ -0,0 +1,30 @@ +
+ +
+ {{ zoomLabel() }} +
+ + +
+ diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.scss b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.scss new file mode 100644 index 000000000000..609b8f793fa4 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.scss @@ -0,0 +1,44 @@ +@use "variables" as *; + +.zoom-controls { + position: absolute; + top: $spacing-3; + right: $spacing-3; + z-index: 5; + display: flex; + align-items: center; + gap: $spacing-1; + padding: $spacing-2; + background: rgba(255, 255, 255, 0.9); + border: 1px solid $color-palette-gray-200; + border-radius: $border-radius-lg; + backdrop-filter: blur(6px); +} + +.zoom-controls__btn { + appearance: none; + border: 1px solid $color-palette-gray-300; + background: $white; + color: $color-palette-gray-900; + border-radius: $border-radius-md; + padding: 0 $spacing-2; + height: 2rem; + font-weight: $font-weight-medium-bold; + cursor: pointer; + + &:hover { + background: $color-palette-gray-100; + } +} + +.zoom-controls__btn--reset { + padding: 0 $spacing-3; +} + +.zoom-controls__label { + min-width: 3.5rem; + text-align: center; + font-weight: $font-weight-bold; + color: $color-palette-bluegray-700; +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.ts new file mode 100644 index 000000000000..1b9d2483b397 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/dot-uve-zoom-controls.component.ts @@ -0,0 +1,33 @@ +import { Component, inject } from '@angular/core'; +import { DotUveZoomService } from '../../../services/dot-uve-zoom/dot-uve-zoom.service'; + +@Component({ + selector: 'dot-uve-zoom-controls', + standalone: true, + templateUrl: './dot-uve-zoom-controls.component.html', + styleUrls: ['./dot-uve-zoom-controls.component.scss'], + imports: [] +}) +export class DotUveZoomControlsComponent { + protected readonly zoomService = inject(DotUveZoomService); + + readonly $zoomLevel = this.zoomService.$zoomLevel; + readonly $zoomLabel = this.zoomService.zoomLabel.bind(this.zoomService); + + zoomIn(): void { + this.zoomService.zoomIn(); + } + + zoomOut(): void { + this.zoomService.zoomOut(); + } + + resetView(): void { + this.zoomService.resetZoom(); + } + + zoomLabel(): string { + return this.zoomService.zoomLabel(); + } +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index 4be572c1babd..959ce99dc1e7 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -34,35 +34,7 @@ [ngClass]="{ 'editor-content-preview': $isPreviewMode() }" data-testId="editor-content">
-
- -
- {{ zoomLabel() }} -
- - -
+
@@ -75,18 +47,18 @@ @if (uveStore.status() === UVE_STATUS.ERROR) { } - + data-testId="iframe"> + @if ($editorProps().progressBar) { ; + @ViewChild('iframe') iframeComponent!: DotUveIframeComponent; @ViewChild('blockSidebar') blockSidebar: DotBlockEditorSidebarComponent; @ViewChild('customDragImage') customDragImage: ElementRef; @ViewChild('zoomContainer') zoomContainer!: ElementRef; @ViewChild('editorContent') editorContent!: ElementRef; + get iframe(): ElementRef | undefined { + return this.iframeComponent?.iframe; + } + protected readonly uveStore = inject(UVEStore); private readonly dotMessageService = inject(DotMessageService); private readonly confirmationService = inject(ConfirmationService); @@ -162,20 +161,14 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit private readonly dotWorkflowActionsFireService = inject(DotWorkflowActionsFireService); private readonly inlineEditingService = inject(InlineEditService); private readonly dotPageApiService = inject(DotPageApiService); + private readonly zoomService = inject(DotUveZoomService); + private readonly bridgeService = inject(DotUveBridgeService); + private readonly actionsHandler = inject(DotUveActionsHandlerService); + private readonly dragDropService = inject(DotUveDragDropService); readonly #destroyRef = inject(DestroyRef); readonly #dotAlertConfirmService = inject(DotAlertConfirmService); #iframeResizeObserver: ResizeObserver | null = null; - // Zoom and pan state - readonly $zoomLevel = signal(1); - readonly $isZoomMode = signal(false); // Ctrl/Cmd pressed - #zoomModeResetTimeout: ReturnType | null = null; - #gestureStartZoom = 1; - readonly $iframeDocHeight = signal(0); - #didSetInitialScroll = false; - #iframeContentResizeObserver: ResizeObserver | null = null; - #iframeMutationObserver: MutationObserver | null = null; - readonly host = '*'; readonly $ogTags: WritableSignal = signal(undefined); readonly $editorProps = this.uveStore.$editorProps; @@ -197,28 +190,24 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit return this.$paletteOpen() ? PALETTE_CLASSES.OPEN : PALETTE_CLASSES.CLOSED; }); - readonly $canvasOuterStyles = computed(() => { - const zoom = this.$zoomLevel(); - const height = this.$iframeDocHeight() || 800; - return { - width: `${1520 * zoom}px`, - height: `${height * zoom}px` - }; - }); + readonly $canvasOuterStyles = this.zoomService.$canvasOuterStyles; + readonly $canvasInnerStyles = this.zoomService.$canvasInnerStyles; - readonly $canvasInnerStyles = computed(() => { - const zoom = this.$zoomLevel(); - const height = this.$iframeDocHeight() || 800; - return { - width: `1520px`, - height: `${height}px`, - transform: `scale(${zoom})`, - transformOrigin: 'top left' - }; + readonly $iframeSrc = computed((): string => { + const url = this.uveStore.$iframeURL(); + return (typeof url === 'string' ? url : '') || ''; + }); + readonly $iframePointerEvents = computed((): string => { + const events = this.$editorProps().iframe.pointerEvents; + return (typeof events === 'string' ? events : '') || ''; + }); + readonly $iframeOpacity = computed((): number => { + const opacity = this.$editorProps().iframe.opacity; + return (typeof opacity === 'number' ? opacity : 1) || 1; }); get contentWindow(): Window | null { - return this.iframe?.nativeElement?.contentWindow || null; + return this.iframeComponent?.contentWindow || null; } readonly $translatePageEffect = effect(() => { @@ -256,24 +245,141 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit return; } - this.contentWindow?.postMessage( + this.bridgeService.sendMessageToIframe( { name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS }, this.host ); - }); ngOnInit(): void { - this.handleDragEvents(); - - fromEvent(this.window, 'message') - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event) => this.#handleWindowMessage(event)); + // Initialization happens in ngAfterViewInit when ViewChild references are available + // This lifecycle hook satisfies OnInit interface requirement + if (!this.uveStore) { + // Early validation - will never execute in normal flow + throw new Error('UVEStore not available'); + } } ngAfterViewInit(): void { + if (!this.iframe) { + return; + } + + // Bridge service handles message events - needs iframe which is available now + const messageStream = this.bridgeService.initialize( + this.iframe, + this.editorContent, + this.zoomService + ); + + messageStream.subscribe((event) => { + this.bridgeService.handleMessage( + event, + (message) => this.handleUveMessage(message), + () => this.#clampScrollWithinBounds() + ); + }); + this.#setupContentletAreaReset(); - this.#setupZoomInteractions(); + this.setupZoom(); + this.setupDragDrop(); + } + + private setupZoom(): void { + const zoomContainer = this.zoomContainer?.nativeElement; + const editorContent = this.editorContent?.nativeElement; + + if (!zoomContainer || !editorContent) { + return; + } + + this.zoomService.setupZoomInteractions( + zoomContainer, + editorContent, + () => this.#clampScrollWithinBounds() + ); + } + + private setupDragDrop(): void { + if (!this.iframe) { + return; + } + + this.dragDropService.setupDragEvents( + this.uveStore, + this.iframe, + this.customDragImage, + this.contentWindow, + this.host, + { + onDrop: (event) => this.handleDrop(event), + onDragEnter: () => { + // Handled in dragDropService + }, + onDragOver: () => { + // Handled in dragDropService + }, + onDragLeave: () => { + this.uveStore.resetEditorProperties(); + }, + onDragEnd: () => { + this.uveStore.resetEditorProperties(); + }, + onDragStart: () => { + // Handled in dragDropService + } + } + ); + } + + private handleUveMessage(message: PostMessage): void { + this.actionsHandler.handleAction(message, { + uveStore: this.uveStore, + dialog: this.dialog, + blockSidebar: this.blockSidebar, + inlineEditingService: this.inlineEditingService, + dotPageApiService: this.dotPageApiService, + contentWindow: this.contentWindow, + host: this.host, + onCopyContent: (currentTreeNode) => this.handleCopyContent(currentTreeNode) + }); + } + + private handleDrop(event: DragEvent): void { + event.preventDefault(); + const target = event.target as HTMLDivElement; + const { position, payload, dropzone } = target.dataset; + + if (dropzone !== 'true') { + this.uveStore.resetEditorProperties(); + return; + } + + const data: ClientData = JSON.parse(payload || '{}'); + const file = event.dataTransfer?.files[0]; + const dragItem = this.uveStore.dragItem(); + + if (file) { + this.handleFileUpload({ + file, + data, + position, + dragItem + }); + return; + } + + if (!isEqual(dragItem, TEMPORAL_DRAG_ITEM) && dragItem) { + const positionPayload = { + position, + ...data + }; + + this.placeItem(positionPayload, dragItem); + return; + } + + this.uveStore.resetEditorProperties(); } /** @@ -310,7 +416,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit * Handles the inline editing functionality triggered by a mouse event. * @param e - The mouse event that triggered the inline editing. */ - handleInlineEditing(e: MouseEvent) { + handleInlineEditing(e: MouseEvent): void { const target = e.target as HTMLElement; const element: HTMLElement = target.dataset?.mode ? target : target.closest('[data-mode]'); @@ -320,205 +426,11 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.inlineEditingService.handleInlineEdit({ ...element.dataset - } as unknown as InlineEditingContentletDataset); - } - - handleDragEvents() { - fromEvent(this.window, 'dragstart') - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event: DragEvent) => { - const { dataset } = event.target as HTMLDivElement; - const data = getDragItemData(dataset); - const shouldUseCustomDragImage = dataset.useCustomDragImage === 'true'; - - if (shouldUseCustomDragImage) { - this.setDragImage(event); - } - - // Needed to identify if a dotcms dragItem from the window left and came back - // More info: https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setData - event.dataTransfer?.setData('dotcms/item', ''); - - // If there is no data, we do nothing because it's not a valid dragItem - if (!data) { - return; - } - - // Wait for the browser to finish initializing the drag before hiding controls - requestAnimationFrame(() => this.uveStore.setEditorDragItem(data)); - }); - - fromEvent(this.window, 'dragenter') - .pipe( - takeUntilDestroyed(this.#destroyRef), - // For some reason the fromElement is not in the DragEvent type - filter((event: DragEvent & { fromElement: HTMLElement }) => !event.fromElement) // I just want to trigger this when we are dragging from the outside - ) - .subscribe((event: DragEvent) => { - event.preventDefault(); - - const types = event.dataTransfer?.types || []; - const dragItem = this.uveStore.dragItem(); - - // Identify if the dotcms dragItem entered the editor from the outside - // We do not set dragging state, forcing users to do the dragging action again - // This check does not apply if users drag something from their computer - // More info: https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types - if (!dragItem && types.includes('dotcms/item')) { - return; - } - - this.uveStore.setEditorState(EDITOR_STATE.DRAGGING); - this.contentWindow?.postMessage( - { - name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS - }, - this.host - ); - - if (dragItem) { - return; - } - - this.uveStore.setEditorDragItem(TEMPORAL_DRAG_ITEM); - }); - - fromEvent(this.window, 'dragend') - .pipe( - takeUntilDestroyed(this.#destroyRef), - filter((event: DragEvent) => event.dataTransfer.dropEffect === 'none') - ) - .subscribe(() => { - this.uveStore.resetEditorProperties(); - }); - - fromEvent(this.window, 'dragover') - .pipe( - takeUntilDestroyed(this.#destroyRef), - // Check that `dragItem()` is not empty because there is a scenario where a dragover - // occurs over the editor after invoking `handleReloadContentEffect`, which clears the dragItem. - // For more details, refer to the issue: https://github.com/dotCMS/core/issues/29855 - filter((_event: DragEvent) => !!this.uveStore.dragItem()) - ) - .subscribe((event: DragEvent) => { - event.preventDefault(); // Prevent file opening - - if (!this.iframe?.nativeElement) { - return; - } - - const iframeRect = this.iframe.nativeElement.getBoundingClientRect(); - - const isInsideIframe = - event.clientX > iframeRect.left && event.clientX < iframeRect.right; - - if (!isInsideIframe) { - this.uveStore.setEditorState(EDITOR_STATE.DRAGGING); - - return; - } - - let direction: 'up' | 'down'; - - if ( - event.clientY > iframeRect.top && - event.clientY < iframeRect.top + IFRAME_SCROLL_ZONE - ) { - direction = 'up'; - } - - if ( - event.clientY > iframeRect.bottom - IFRAME_SCROLL_ZONE && - event.clientY <= iframeRect.bottom - ) { - direction = 'down'; - } - - if (!direction) { - this.uveStore.setEditorState(EDITOR_STATE.DRAGGING); - - return; - } - - this.uveStore.updateEditorScrollDragState(); - - this.contentWindow?.postMessage( - { name: __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME, direction }, - this.host - ); - }); - - fromEvent(this.window, 'dragleave') - .pipe( - takeUntilDestroyed(this.#destroyRef), - filter((event: DragEvent) => !event.relatedTarget) // Just reset when is out of the window - ) - .subscribe(() => { - this.uveStore.resetEditorProperties(); - }); - - fromEvent(this.window, 'drop') - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event: DragEvent) => { - event.preventDefault(); - const target = event.target as HTMLDivElement; - const { position, payload, dropzone } = target.dataset; - - // If we drop in a container that is not a dropzone, we just reset the editor state - if (dropzone !== 'true') { - this.uveStore.resetEditorProperties(); - - return; - } - - const data: ClientData = JSON.parse(payload); - const file = event.dataTransfer?.files[0]; // We are sure that is comes but in the tests we don't have DragEvent class - const dragItem = this.uveStore.dragItem(); - - // If we have a file, we need to upload it - if (file) { - // I need to publish the temp file to use it. - this.handleFileUpload({ - file, - data, - position, - dragItem - }); - - return; - } - - // If we have a dragItem, we need to place it - if (!isEqual(dragItem, TEMPORAL_DRAG_ITEM)) { - const positionPayload = { - position, - ...data - }; - - this.placeItem(positionPayload, dragItem); - - return; - } - - this.uveStore.resetEditorProperties(); - }); + } as unknown as { language: string; mode: string; inode: string; fieldName: string }); } - /** - * Handle the iframe page load - * - * @param {string} clientHost - * @memberof EditEmaEditorComponent - */ - onIframePageLoad() { - if (!this.uveStore.isTraditionalPage()) { - return; - } - - this.#insertPageContent(); - this.#setSeoData(); - this.#setupIframeAutoHeight(); + onIframePageLoad(): void { if (this.uveStore.state() === EDITOR_STATE.INLINE_EDITING) { this.inlineEditingService.initEditor(); } @@ -526,91 +438,15 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.setIsClientReady(true); } - /** - * Add the editor page script to VTL pages - * - * @param {string} rendered - * @return {*} - * @memberof EditEmaEditorComponent - */ - addEditorPageScript(rendered = ''): string { - const scriptString = ``; - const bodyExists = rendered.includes(''); - - /* - * For advance template case. It might not include `body` tag. - */ - if (!bodyExists) { - return rendered + scriptString; - } - - const updatedRendered = rendered.replace('', scriptString + ''); - - return updatedRendered; - } - - /** - * Add custom styles to the rendered content - * - * @param {string} rendered - * @return {*} - * @memberof EditEmaEditorComponent - */ - addCustomStyles(rendered = ''): string { - const styles = ` - `; - - const headExists = rendered.includes(''); - - /* - * For advance template case. It might not include `head` tag. - */ - if (!headExists) { - return rendered + styles; - } - - return rendered.replace('', styles + ''); + onIframeDocHeightChange(height: number): void { + this.zoomService.setIframeDocHeight(height); + this.#clampScrollWithinBounds(); } - /** - * Inject the editor page script and styles to the VTL content - * - * @private - * @param {string} rendered - * @return {*} {string} - * @memberof EditEmaEditorComponent - */ - private inyectCodeToVTL(rendered: string): string { - const fileWithScript = this.addEditorPageScript(rendered); - const fileWithStylesAndScript = this.addCustomStyles(fileWithScript); - - return fileWithStylesAndScript; - } ngOnDestroy(): void { this.#iframeResizeObserver?.disconnect(); this.#iframeResizeObserver = null; - this.#teardownIframeAutoHeight(); if (this.uveStore.isTraditionalPage()) { this.uveStore.setIsClientReady(true); } @@ -727,63 +563,12 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit }); } - /** - * - * Sets the content of the iframe with the provided code. - * @param code - The code to be added to the iframe. - * @memberof EditEmaEditorComponent - */ - #insertPageContent(): void { - const iframeElement = this.iframe?.nativeElement; - - if (!iframeElement) { - return; - } - - const doc = iframeElement.contentDocument; - - const enableInlineEdit = this.uveStore.$enableInlineEdit(); - const pageRender = this.uveStore.$pageRender(); - - const newDoc = this.inyectCodeToVTL(pageRender); - - if (!doc) { - return; - } - - doc.open(); - doc.write(newDoc); - doc.close(); - - this.handleInlineScripts(enableInlineEdit); + handleInternalNavFromIframe(e: MouseEvent): void { + this.handleInternalNav(e); } - /** - * Handle the Injection and removal of the inline editing scripts - * - * @param {boolean} enableInlineEdit - * @return {*} - * @memberof EditEmaEditorComponent - */ - handleInlineScripts(enableInlineEdit: boolean) { - const win = this.contentWindow; - - if (!win) { - return; - } - - fromEvent(win, 'click').subscribe((e: MouseEvent) => { - this.handleInternalNav(e); - this.handleInlineEditing(e); // If inline editing is not active this will do nothing - }); - - if (enableInlineEdit) { - this.inlineEditingService.injectInlineEdit(this.iframe); - - return; - } - - this.inlineEditingService.removeInlineEdit(this.iframe); + handleInlineEditingFromIframe(e: MouseEvent): void { + this.handleInlineEditing(e); } protected handleNgEvent({ event, actionPayload, clientAction }: DialogAction) { @@ -824,7 +609,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit } if (clientAction === DotCMSUVEAction.EDIT_CONTENTLET) { - this.contentWindow?.postMessage( + this.bridgeService.sendMessageToIframe( { name: __DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE }, @@ -898,7 +683,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit // This is a temporary solution to "reload" the content by reloading the window // we should change this with a new SDK reload strategy - this.contentWindow?.postMessage( + this.bridgeService.sendMessageToIframe( { name: __DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE }, @@ -952,266 +737,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit * @return {*} * @memberof DotEmaComponent */ - private handlePostMessage({ action, payload }: PostMessage): void { - const CLIENT_ACTIONS_FUNC_MAP = { - [DotCMSUVEAction.NAVIGATION_UPDATE]: (payload: SetUrlPayload) => { - // When we set the url, we trigger in the shell component a load to get the new state of the page - // This triggers a rerender that makes nextjs to send the set_url again - // But this time the params are the same so the shell component wont trigger a load and there we know that the page is loaded - const isSameUrl = compareUrlPaths(this.uveStore.pageParams()?.url, payload.url); - - if (isSameUrl) { - this.uveStore.setEditorState(EDITOR_STATE.IDLE); - } else { - this.uveStore.loadPageAsset({ - url: payload.url, - [PERSONA_KEY]: DEFAULT_PERSONA.identifier - }); - } - }, - [DotCMSUVEAction.SET_BOUNDS]: (payload: Container[]) => { - this.uveStore.setEditorBounds(payload); - }, - [DotCMSUVEAction.SET_CONTENTLET]: (coords: ClientContentletArea) => { - const payload = this.uveStore.getPageSavePayload(coords.payload); - - this.uveStore.setContentletArea({ - x: coords.x, - y: coords.y, - width: coords.width, - height: coords.height, - payload - }); - }, - [DotCMSUVEAction.IFRAME_SCROLL]: () => { - this.uveStore.updateEditorScrollState(); - }, - [DotCMSUVEAction.IFRAME_SCROLL_END]: () => { - // TODO: Maybe add a small debounce to avoid multiple calls - this.uveStore.updateEditorOnScrollEnd(); - }, - [DotCMSUVEAction.COPY_CONTENTLET_INLINE_EDITING]: (payload: { - dataset: InlineEditingContentletDataset; - }) => { - // The iframe say the contentlet that the content is queue to be inline edited is in multiple pages - // So the editor should open the dialog to ask if the edit is in ALL contentlets or only in this page. - - if (this.uveStore.state() === EDITOR_STATE.INLINE_EDITING) { - return; - } - - const { contentlet, container } = this.uveStore.contentArea().payload; - - const currentTreeNode = this.uveStore.getCurrentTreeNode(container, contentlet); - - this.dotCopyContentModalService - .open() - .pipe( - switchMap(({ shouldCopy }) => { - if (!shouldCopy) { - return of(null); - } - - return this.handleCopyContent(currentTreeNode); - }), - tap((res) => { - this.uveStore.setEditorState(EDITOR_STATE.INLINE_EDITING); - - if (res) { - this.uveStore.reloadCurrentPage(); - } - }) - ) - .subscribe((res: DotCMSContentlet | null) => { - const data = { - oldInode: payload.dataset.inode, - inode: res?.inode || payload.dataset.inode, - fieldName: payload.dataset.fieldName, - mode: payload.dataset.mode, - language: payload.dataset.language - }; - - if (!this.uveStore.isTraditionalPage()) { - const message = { - name: __DOTCMS_UVE_EVENT__.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS, - payload: data - }; - - this.contentWindow?.postMessage(message, this.host); - - return; - } - - this.inlineEditingService.setTargetInlineMCEDataset(data); - - if (!res) { - this.inlineEditingService.initEditor(); - } - }); - }, - [DotCMSUVEAction.UPDATE_CONTENTLET_INLINE_EDITING]: (payload: UpdatedContentlet) => { - this.uveStore.setEditorState(EDITOR_STATE.IDLE); - - // If there is no payload, we don't need to do anything - if (!payload) { - return; - } - - const dataset = payload.dataset; - - const contentlet = { - inode: dataset['inode'], - [dataset.fieldName]: payload.content - }; - - this.uveStore.setUveStatus(UVE_STATUS.LOADING); - this.dotPageApiService - .saveContentlet({ contentlet }) - .pipe( - take(1), - tapResponse({ - next: () => { - this.messageService.add({ - severity: 'success', - summary: this.dotMessageService.get('message.content.saved'), - detail: this.dotMessageService.get( - 'message.content.note.already.published' - ), - life: 2000 - }); - }, - error: (e) => { - console.error(e); - this.messageService.add({ - severity: 'error', - summary: this.dotMessageService.get( - 'editpage.content.update.contentlet.error' - ), - life: 2000 - }); - } - }) - ) - .subscribe(() => this.uveStore.reloadCurrentPage()); - }, - [DotCMSUVEAction.CLIENT_READY]: (devConfig) => { - const isClientReady = this.uveStore.isClientReady(); - - if (isClientReady) { - return; - } - - const { graphql, params, query: rawQuery } = devConfig || {}; - const { query = rawQuery, variables } = graphql || {}; - const legacyGraphqlResponse = !!rawQuery; - - if (query || rawQuery) { - this.uveStore.setCustomGraphQL({ query, variables }, legacyGraphqlResponse); - } - - const pageParams = convertClientParamsToPageParams(params); - this.uveStore.reloadCurrentPage(pageParams); - this.uveStore.setIsClientReady(true); - }, - [DotCMSUVEAction.EDIT_CONTENTLET]: (contentlet: DotCMSContentlet) => { - this.dialog.editContentlet({ ...contentlet, clientAction: action }); - }, - [DotCMSUVEAction.REORDER_MENU]: ({ startLevel, depth }: ReorderMenuPayload) => { - const urlObject = createReorderMenuURL({ - startLevel, - depth, - pagePath: this.uveStore.pageParams().url, - hostId: this.uveStore.pageAPIResponse().site.identifier - }); - - this.dialog.openDialogOnUrl( - urlObject, - this.dotMessageService.get('editpage.content.contentlet.menu.reorder.title') - ); - }, - [DotCMSUVEAction.INIT_INLINE_EDITING]: (payload) => - this.#handleInlineEditingEvent(payload), - - [DotCMSUVEAction.REGISTER_STYLE_SCHEMAS]: (payload: { - schemas: StyleEditorFormSchema[]; - }) => { - const { schemas } = payload; - this.uveStore.setStyleSchemas(schemas); - }, - [DotCMSUVEAction.NOOP]: () => { - /* Do Nothing because is not the origin we are expecting */ - } - }; - const actionToExecute = CLIENT_ACTIONS_FUNC_MAP[action]; - actionToExecute?.(payload); - } - - #handleWindowMessage(event: MessageEvent): void { - // 1) Cross-origin iframe height bridge (e.g. Next.js at localhost:3000) - // Expected shape: - // { name: 'dotcms:iframeHeight', payload: { height: number } } - // or { type: 'dotcms:iframeHeight', height: number } - if (this.#maybeHandleIframeHeightMessage(event)) { - return; - } - - // 2) UVE messages - const data = event.data; - if (this.#isUvePostMessage(data)) { - this.handlePostMessage(data); - } - } - - #isUvePostMessage(data: unknown): data is PostMessage { - return ( - !!data && - typeof data === 'object' && - 'action' in data && - // payload can be any shape depending on action - true - ); - } - - #maybeHandleIframeHeightMessage(event: MessageEvent): boolean { - const iframeEl = this.iframe?.nativeElement; - if (!iframeEl?.contentWindow) { - return false; - } - - // Only accept messages from the current iframe window. - if (event.source !== iframeEl.contentWindow) { - return false; - } - - const data = event.data as unknown; - if (!data || typeof data !== 'object') { - return false; - } - - const record = data as Record; - const isNamed = - record['name'] === 'dotcms:iframeHeight' && typeof record['payload'] === 'object'; - const isTyped = record['type'] === 'dotcms:iframeHeight'; - - let height: number | null = null; - if (isNamed) { - const payload = record['payload'] as Record; - height = typeof payload['height'] === 'number' ? payload['height'] : null; - } else if (isTyped) { - height = typeof record['height'] === 'number' ? (record['height'] as number) : null; - } - - if (!height || !Number.isFinite(height) || height <= 0) { - return false; - } - - // Apply height so iframe never scrolls; also update our layout sizing for zoom/scroll. - iframeEl.style.height = `${Math.ceil(height)}px`; - this.$iframeDocHeight.set(Math.ceil(height)); - this.#clampScrollWithinBounds(); - - return true; - } /** * Notify the user to reload the iframe @@ -1219,8 +744,8 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit * @private * @memberof DotEmaComponent */ - reloadIframeContent() { - this.iframe?.nativeElement?.contentWindow?.postMessage( + reloadIframeContent(): void { + this.bridgeService.sendMessageToIframe( { name: __DOTCMS_UVE_EVENT__.UVE_SET_PAGE_DATA, payload: this.#clientPayload() @@ -1618,25 +1143,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.loadPageAsset({ language_id: '1' }); } - #setSeoData() { - const iframeElement = this.iframe?.nativeElement; - - if (!iframeElement) { - return; - } - - const doc = iframeElement.contentDocument; - - if (!doc) { - return; - } - - this.dotSeoMetaTagsService.getMetaTagsResults(doc).subscribe((results) => { - const ogTags = this.dotSeoMetaTagsUtilService.getMetaTags(doc); - this.uveStore.setOgTags(ogTags); - this.uveStore.setOGTagResults(results); - }); - } #clientPayload() { const graphqlResponse = this.uveStore.$customGraphqlResponse(); @@ -1675,263 +1181,13 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.resetContentletArea(); } - #teardownIframeAutoHeight(): void { - this.#iframeContentResizeObserver?.disconnect(); - this.#iframeContentResizeObserver = null; - this.#iframeMutationObserver?.disconnect(); - this.#iframeMutationObserver = null; - } - - #setupIframeAutoHeight(): void { - // Traditional pages are written into the iframe via doc.write(), so they are same-origin - // and we can measure scrollHeight safely. - const iframeEl = this.iframe?.nativeElement; - - if (!iframeEl) { - return; - } - - // Clean previous observers (reloads, navigation, etc.) - this.#teardownIframeAutoHeight(); - - let doc: Document | null = null; - let win: Window | null = null; - try { - doc = iframeEl.contentDocument; - win = iframeEl.contentWindow; - } catch { - // Cross-origin; height must be provided via postMessage. - return; - } - - if (!doc || !win) { - return; - } - - const updateHeight = () => { - const body = doc.body; - const root = doc.documentElement; - - if (!body || !root) { - return; - } - - // Take the max between body/root to handle different doc modes. - const height = Math.max(body.scrollHeight, root.scrollHeight); - - // Set explicit px height so the iframe itself never scrolls. - iframeEl.style.height = `${height}px`; - this.$iframeDocHeight.set(height); - this.#clampScrollWithinBounds(); - - // First time we know the real dimensions, start at top-left so header is visible. - if (!this.#didSetInitialScroll) { - this.#didSetInitialScroll = true; - this.#scrollToTopLeft(); - } - }; - - // Initial sizing after layout settles. - requestAnimationFrame(() => { - updateHeight(); - requestAnimationFrame(updateHeight); - }); - - // Keep height synced as the iframe content changes. - if (typeof ResizeObserver !== 'undefined') { - this.#iframeContentResizeObserver = new ResizeObserver(() => updateHeight()); - // Observe both body and root (some layouts expand root, some body). - this.#iframeContentResizeObserver.observe(doc.body); - this.#iframeContentResizeObserver.observe(doc.documentElement); - } - - if (typeof MutationObserver !== 'undefined') { - this.#iframeMutationObserver = new MutationObserver(() => updateHeight()); - this.#iframeMutationObserver.observe(doc.documentElement, { - childList: true, - subtree: true, - attributes: true, - characterData: true - }); - } - - // Fonts/images can load async and change height. - fromEvent(win, 'load') - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(() => updateHeight()); - } - - #setupZoomInteractions(): void { - const zoomContainer = this.zoomContainer?.nativeElement; - const editorContent = this.editorContent?.nativeElement; - - if (!zoomContainer || !editorContent) { - return; - } - - type GestureLikeEvent = Event & { - scale?: number; - clientX: number; - clientY: number; - preventDefault: () => void; - }; - - // Track zoom modifier keys (optional, to indicate "zoom mode" + support Safari gestures). - fromEvent(this.window, 'keydown') - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event) => { - if (event.key === 'Control' || event.code.startsWith('Control')) { - this.$isZoomMode.set(true); - } - if (event.key === 'Meta' || event.code.startsWith('Meta')) { - this.$isZoomMode.set(true); - } - }); - - fromEvent(this.window, 'keyup') - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event) => { - if (event.key === 'Control' || event.code.startsWith('Control')) { - this.$isZoomMode.set(false); - } - if (event.key === 'Meta' || event.code.startsWith('Meta')) { - this.$isZoomMode.set(false); - } - }); - - const applyZoomFromWheel = (event: WheelEvent) => { - // Trackpad pinch zoom (and Ctrl/Cmd+wheel) sets ctrlKey/metaKey. - if (!event.ctrlKey && !event.metaKey) { - return; - } - - // Only hijack zoom if the pointer is inside the editor content region. - const rect = editorContent.getBoundingClientRect(); - const insideEditor = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - - if (!insideEditor) { - return; - } - - // Critical: stops the browser from zooming the whole viewport. - event.preventDefault(); - - this.$isZoomMode.set(true); - if (this.#zoomModeResetTimeout) { - clearTimeout(this.#zoomModeResetTimeout); - } - this.#zoomModeResetTimeout = setTimeout(() => this.$isZoomMode.set(false), 150); - - const delta = event.deltaY > 0 ? -0.1 : 0.1; - const newZoom = Math.max(0.1, Math.min(3, this.$zoomLevel() + delta)); - this.$zoomLevel.set(newZoom); - this.#clampScrollWithinBounds(); - }; - - // Zoom with mouse wheel on the canvas container - fromEvent(zoomContainer, 'wheel', { passive: false }) - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(applyZoomFromWheel); - - // Zoom with mouse wheel on the whole editor content area - fromEvent(editorContent, 'wheel', { passive: false }) - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(applyZoomFromWheel); - - // Trackpad pinch can still trigger browser zoom if the event originates from the iframe. - // Capture wheel at window-level and preventDefault when pointer is inside editor. - fromEvent(this.window, 'wheel', { passive: false, capture: true } as AddEventListenerOptions) - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(applyZoomFromWheel); - - // Safari trackpad pinch uses non-standard GestureEvents (best-effort support). - fromEvent( - this.window, - 'gesturestart', - { passive: false, capture: true } as AddEventListenerOptions - ) - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event) => { - const rect = editorContent.getBoundingClientRect(); - const insideEditor = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - - if (!insideEditor) { - return; - } - - event.preventDefault(); - this.$isZoomMode.set(true); - this.#gestureStartZoom = this.$zoomLevel(); - }); - fromEvent( - this.window, - 'gesturechange', - { passive: false, capture: true } as AddEventListenerOptions - ) - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event) => { - const rect = editorContent.getBoundingClientRect(); - const insideEditor = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - - if (!insideEditor) { - return; - } - event.preventDefault(); - const scale = typeof event.scale === 'number' ? event.scale : 1; - const newZoom = Math.max(0.1, Math.min(3, this.#gestureStartZoom * scale)); - this.$zoomLevel.set(newZoom); - }); - - fromEvent( - this.window, - 'gestureend', - { passive: false, capture: true } as AddEventListenerOptions - ) - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event) => { - event.preventDefault(); - this.$isZoomMode.set(false); - }); - - // No panning: scrolling on editorContent is the navigation mechanism. - } protected handleSelectContent(contentlet: ContentletPayload): void { this.uveStore.setActiveContentlet(contentlet); } - zoomIn(): void { - this.$zoomLevel.set(Math.max(0.1, Math.min(3, this.$zoomLevel() + 0.1))); - this.#clampScrollWithinBounds(); - } - - zoomOut(): void { - this.$zoomLevel.set(Math.max(0.1, Math.min(3, this.$zoomLevel() - 0.1))); - this.#clampScrollWithinBounds(); - } - - resetView(): void { - this.$zoomLevel.set(1); - this.#scrollToTopLeft(); - } - - zoomLabel(): string { - return `${Math.round(this.$zoomLevel() * 100)}%`; - } #scrollToTopLeft(): void { const el = this.editorContent?.nativeElement; @@ -1939,7 +1195,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit return; } - // Let layout apply widths/heights first. requestAnimationFrame(() => { el.scrollLeft = 0; el.scrollTop = 0; @@ -1962,21 +1217,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit }); } - /** - * Applies the custom drag preview used when the drag originates from the - * contentlet controls (identified via `data-drag-origin="contentlet-controls"`). - * Keeping this logic here ensures future contributors know where the drag - * control trigger lives. - * - * @param event - The drag event. - */ - protected setDragImage(event: DragEvent): void { - if (!event.dataTransfer) { - return; - } - - event.dataTransfer.setDragImage(this.customDragImage.nativeElement, 0, 0); - } protected handleTabChange(tab: UVE_PALETTE_TABS): void { this.uveStore.setPaletteTab(tab); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts new file mode 100644 index 000000000000..68f6e1c957fc --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts @@ -0,0 +1,302 @@ +import { Injectable, inject } from '@angular/core'; +import { EMPTY, Observable, of } from 'rxjs'; +import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { tapResponse } from '@ngrx/operators'; +import { MessageService } from 'primeng/api'; +import { + DotCopyContentService, + DotHttpErrorManagerService, + DotMessageService, + DotContentletService +} from '@dotcms/data-access'; +import { + DotCMSContentlet, + DotLanguage, + DotTreeNode +} from '@dotcms/dotcms-models'; +import { + DotCMSInlineEditingPayload, + DotCMSInlineEditingType, + DotCMSPage, + DotCMSUVEAction +} from '@dotcms/types'; +import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; +import { DotCopyContentModalService } from '@dotcms/ui'; +import { isEqual } from '@dotcms/utils'; +import { StyleEditorFormSchema } from '@dotcms/uve'; + +import { DotPageApiService } from '../dot-page-api.service'; +import { InlineEditService } from '../inline-edit/inline-edit.service'; +import { UVEStore } from '../../store/dot-uve.store'; +import { EDITOR_STATE, NG_CUSTOM_EVENTS, UVE_STATUS } from '../../shared/enums'; +import { PostMessage, ReorderMenuPayload, SetUrlPayload } from '../../shared/models'; +import { + ClientContentletArea, + Container, + InlineEditingContentletDataset, + UpdatedContentlet +} from '../../edit-ema-editor/components/ema-page-dropzone/types'; +import { + compareUrlPaths, + convertClientParamsToPageParams, + createReorderMenuURL +} from '../../utils'; +import { DEFAULT_PERSONA, PERSONA_KEY } from '../../shared/consts'; +import { DotEmaDialogComponent } from '../../components/dot-ema-dialog/dot-ema-dialog.component'; +import { DotBlockEditorSidebarComponent } from '../../components/dot-block-editor-sidebar/dot-block-editor-sidebar.component'; + +export interface ActionsHandlerDependencies { + uveStore: InstanceType; + dialog: DotEmaDialogComponent; + blockSidebar?: DotBlockEditorSidebarComponent; + inlineEditingService: InlineEditService; + dotPageApiService: DotPageApiService; + contentWindow: Window | null; + host: string; + onCopyContent: (currentTreeNode: DotTreeNode) => Observable; +} + +@Injectable() +export class DotUveActionsHandlerService { + private readonly dotMessageService = inject(DotMessageService); + private readonly messageService = inject(MessageService); + private readonly dotCopyContentModalService = inject(DotCopyContentModalService); + private readonly dotCopyContentService = inject(DotCopyContentService); + private readonly dotHttpErrorManagerService = inject(DotHttpErrorManagerService); + private readonly dotContentletService = inject(DotContentletService); + + handleAction( + { action, payload }: PostMessage, + deps: ActionsHandlerDependencies + ): void { + const { + uveStore, + dialog, + blockSidebar, + inlineEditingService, + dotPageApiService, + contentWindow, + host, + onCopyContent + } = deps; + + const CLIENT_ACTIONS_FUNC_MAP: Record< + DotCMSUVEAction, + (payload: unknown) => void + > = { + [DotCMSUVEAction.NAVIGATION_UPDATE]: (payload: SetUrlPayload) => { + const isSameUrl = compareUrlPaths(uveStore.pageParams()?.url, payload.url); + + if (isSameUrl) { + uveStore.setEditorState(EDITOR_STATE.IDLE); + } else { + uveStore.loadPageAsset({ + url: payload.url, + [PERSONA_KEY]: DEFAULT_PERSONA.identifier + }); + } + }, + [DotCMSUVEAction.SET_BOUNDS]: (payload: Container[]) => { + uveStore.setEditorBounds(payload); + }, + [DotCMSUVEAction.SET_CONTENTLET]: (coords: ClientContentletArea) => { + const actionPayload = uveStore.getPageSavePayload(coords.payload); + + uveStore.setContentletArea({ + x: coords.x, + y: coords.y, + width: coords.width, + height: coords.height, + payload: actionPayload + }); + }, + [DotCMSUVEAction.IFRAME_SCROLL]: () => { + uveStore.updateEditorScrollState(); + }, + [DotCMSUVEAction.IFRAME_SCROLL_END]: () => { + uveStore.updateEditorOnScrollEnd(); + }, + [DotCMSUVEAction.COPY_CONTENTLET_INLINE_EDITING]: (payload: { + dataset: InlineEditingContentletDataset; + }) => { + if (uveStore.state() === EDITOR_STATE.INLINE_EDITING) { + return; + } + + const { contentlet, container } = uveStore.contentArea().payload; + const currentTreeNode = uveStore.getCurrentTreeNode(container, contentlet); + + this.dotCopyContentModalService + .open() + .pipe( + switchMap(({ shouldCopy }) => { + if (!shouldCopy) { + return of(null); + } + + return onCopyContent(currentTreeNode); + }), + tap((res) => { + uveStore.setEditorState(EDITOR_STATE.INLINE_EDITING); + + if (res) { + uveStore.reloadCurrentPage(); + } + }) + ) + .subscribe((res: DotCMSContentlet | null) => { + const data = { + oldInode: payload.dataset.inode, + inode: res?.inode || payload.dataset.inode, + fieldName: payload.dataset.fieldName, + mode: payload.dataset.mode, + language: payload.dataset.language + }; + + if (!uveStore.isTraditionalPage()) { + const message = { + name: __DOTCMS_UVE_EVENT__.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS, + payload: data + }; + + contentWindow?.postMessage(message, host); + return; + } + + inlineEditingService.setTargetInlineMCEDataset(data); + + if (!res) { + inlineEditingService.initEditor(); + } + }); + }, + [DotCMSUVEAction.UPDATE_CONTENTLET_INLINE_EDITING]: (payload: UpdatedContentlet) => { + uveStore.setEditorState(EDITOR_STATE.IDLE); + + if (!payload) { + return; + } + + const dataset = payload.dataset; + + const contentlet = { + inode: dataset['inode'], + [dataset.fieldName]: payload.content + }; + + uveStore.setUveStatus(UVE_STATUS.LOADING); + dotPageApiService + .saveContentlet({ contentlet }) + .pipe( + take(1), + tapResponse({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: this.dotMessageService.get('message.content.saved'), + detail: this.dotMessageService.get( + 'message.content.note.already.published' + ), + life: 2000 + }); + }, + error: (e) => { + console.error(e); + this.messageService.add({ + severity: 'error', + summary: this.dotMessageService.get( + 'editpage.content.update.contentlet.error' + ), + life: 2000 + }); + } + }) + ) + .subscribe(() => uveStore.reloadCurrentPage()); + }, + [DotCMSUVEAction.CLIENT_READY]: (devConfig: any) => { + const isClientReady = uveStore.isClientReady(); + + if (isClientReady) { + return; + } + + const { graphql, params, query: rawQuery } = devConfig || {}; + const { query = rawQuery, variables } = graphql || {}; + const legacyGraphqlResponse = !!rawQuery; + + if (query || rawQuery) { + uveStore.setCustomGraphQL({ query, variables }, legacyGraphqlResponse); + } + + const pageParams = convertClientParamsToPageParams(params); + uveStore.reloadCurrentPage(pageParams); + uveStore.setIsClientReady(true); + }, + [DotCMSUVEAction.EDIT_CONTENTLET]: (contentlet: DotCMSContentlet) => { + dialog.editContentlet({ ...contentlet, clientAction: action }); + }, + [DotCMSUVEAction.REORDER_MENU]: ({ startLevel, depth }: ReorderMenuPayload) => { + const urlObject = createReorderMenuURL({ + startLevel, + depth, + pagePath: uveStore.pageParams().url, + hostId: uveStore.pageAPIResponse().site.identifier + }); + + dialog.openDialogOnUrl( + urlObject, + this.dotMessageService.get('editpage.content.contentlet.menu.reorder.title') + ); + }, + [DotCMSUVEAction.INIT_INLINE_EDITING]: (payload: { + type: DotCMSInlineEditingType; + data?: DotCMSInlineEditingPayload; + }) => { + this.handleInlineEditingEvent(payload, deps); + }, + [DotCMSUVEAction.REGISTER_STYLE_SCHEMAS]: (payload: { + schemas: StyleEditorFormSchema[]; + }) => { + const { schemas } = payload; + uveStore.setStyleSchemas(schemas); + }, + [DotCMSUVEAction.NOOP]: () => { + /* Do Nothing because is not the origin we are expecting */ + }, + [DotCMSUVEAction.PING_EDITOR]: () => { + /* Ping editor - no action needed */ + }, + [DotCMSUVEAction.GET_PAGE_DATA]: () => { + /* Get page data - handled by bridge service */ + } + }; + + const actionToExecute = CLIENT_ACTIONS_FUNC_MAP[action]; + actionToExecute?.(payload); + } + + private handleInlineEditingEvent( + { type, data }: { type: DotCMSInlineEditingType; data?: DotCMSInlineEditingPayload }, + deps: ActionsHandlerDependencies + ): void { + const { uveStore, blockSidebar, inlineEditingService } = deps; + + // Note: Enterprise check should be done by caller if needed + switch (type) { + case 'BLOCK_EDITOR': + blockSidebar?.open(data); + break; + + case 'WYSIWYG': + inlineEditingService.initEditor(); + uveStore.setEditorState(EDITOR_STATE.INLINE_EDITING); + break; + + default: + console.warn('Unknown block editor type', type); + break; + } + } +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts new file mode 100644 index 000000000000..b61a67dd311e --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts @@ -0,0 +1,115 @@ +import { Injectable, ElementRef, inject, signal, WritableSignal } from '@angular/core'; +import { fromEvent, Observable } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { WINDOW } from '@dotcms/utils'; +import { DestroyRef } from '@angular/core'; +import { PostMessage } from '../../shared/models'; +import { DotUveZoomService } from '../dot-uve-zoom/dot-uve-zoom.service'; + +export interface IframeHeightMessage { + height: number; +} + +@Injectable() +export class DotUveBridgeService { + private readonly window = inject(WINDOW); + private readonly destroyRef = inject(DestroyRef); + private zoomService?: DotUveZoomService; + private iframeElement?: HTMLIFrameElement; + private editorContent?: HTMLElement; + + private readonly $iframeDocHeight = signal(0); + + initialize( + iframe: ElementRef, + editorContent: ElementRef, + zoomService: DotUveZoomService, + ): Observable { + this.iframeElement = iframe.nativeElement; + this.editorContent = editorContent.nativeElement; + this.zoomService = zoomService; + + return fromEvent(this.window, 'message').pipe( + takeUntilDestroyed(this.destroyRef) + ); + } + + handleMessage(event: MessageEvent, onUveMessage: (message: PostMessage) => void, onClampScroll: () => void): void { + // 1) Cross-origin iframe height bridge (e.g. Next.js at localhost:3000) + if (this.maybeHandleIframeHeightMessage(event, onClampScroll)) { + return; + } + + // 2) UVE messages + const data = event.data; + if (this.isUvePostMessage(data)) { + onUveMessage(data); + } + } + + sendMessageToIframe(message: unknown, host: string = '*'): void { + this.iframeElement?.contentWindow?.postMessage(message, host); + } + + getContentWindow(): Window | null { + return this.iframeElement?.contentWindow || null; + } + + getIframeDocHeight(): number { + return this.$iframeDocHeight(); + } + + private isUvePostMessage(data: unknown): data is PostMessage { + return ( + !!data && + typeof data === 'object' && + 'action' in data + ); + } + + private maybeHandleIframeHeightMessage(event: MessageEvent, onClampScroll: () => void): boolean { + if (!this.iframeElement?.contentWindow) { + return false; + } + + // Only accept messages from the current iframe window + if (event.source !== this.iframeElement.contentWindow) { + return false; + } + + const data = event.data as unknown; + if (!data || typeof data !== 'object') { + return false; + } + + const record = data as Record; + const isNamed = + record['name'] === 'dotcms:iframeHeight' && typeof record['payload'] === 'object'; + const isTyped = record['type'] === 'dotcms:iframeHeight'; + + let height: number | null = null; + if (isNamed) { + const payload = record['payload'] as Record; + height = typeof payload['height'] === 'number' ? payload['height'] : null; + } else if (isTyped) { + height = typeof record['height'] === 'number' ? (record['height'] as number) : null; + } + + if (!height || !Number.isFinite(height) || height <= 0) { + return false; + } + + // Apply height so iframe never scrolls; also update our layout sizing for zoom/scroll + if (this.iframeElement) { + this.iframeElement.style.height = `${Math.ceil(height)}px`; + this.$iframeDocHeight.set(Math.ceil(height)); + if (this.zoomService) { + this.zoomService.setIframeDocHeight(Math.ceil(height)); + } + onClampScroll(); + } + + return true; + } +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts new file mode 100644 index 000000000000..4833255bd8e7 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts @@ -0,0 +1,174 @@ +import { Injectable, inject, DestroyRef, ElementRef } from '@angular/core'; +import { fromEvent, Observable } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs/operators'; +import { WINDOW, isEqual } from '@dotcms/utils'; +import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; +import { EDITOR_STATE } from '../../shared/enums'; +import { IFRAME_SCROLL_ZONE } from '../../shared/consts'; +import { PostMessage } from '../../shared/models'; +import { UVEStore } from '../../store/dot-uve.store'; +import { TEMPORAL_DRAG_ITEM, getDragItemData } from '../../utils'; +import { + ClientContentletArea, + Container, + EmaDragItem +} from '../../edit-ema-editor/components/ema-page-dropzone/types'; + +export interface DragDropHandlers { + onDrop: (event: DragEvent) => void; + onDragEnter: (event: DragEvent) => void; + onDragOver: (event: DragEvent) => void; + onDragLeave: () => void; + onDragEnd: () => void; + onDragStart: (event: DragEvent) => void; +} + +@Injectable() +export class DotUveDragDropService { + private readonly window = inject(WINDOW); + private readonly destroyRef = inject(DestroyRef); + + setupDragEvents( + uveStore: InstanceType, + iframe: ElementRef, + customDragImage: ElementRef, + contentWindow: Window | null, + host: string, + handlers: DragDropHandlers + ): void { + // Drag start + fromEvent(this.window, 'dragstart') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event: DragEvent) => { + const { dataset } = event.target as HTMLDivElement; + const data = getDragItemData(dataset); + const shouldUseCustomDragImage = dataset.useCustomDragImage === 'true'; + + if (shouldUseCustomDragImage && customDragImage?.nativeElement) { + event.dataTransfer?.setDragImage(customDragImage.nativeElement, 0, 0); + } + + event.dataTransfer?.setData('dotcms/item', ''); + + if (!data) { + return; + } + + requestAnimationFrame(() => uveStore.setEditorDragItem(data)); + }); + + // Drag enter + fromEvent(this.window, 'dragenter') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter((event: DragEvent & { fromElement?: HTMLElement }) => !event.fromElement) + ) + .subscribe((event: DragEvent) => { + event.preventDefault(); + + const types = event.dataTransfer?.types || []; + const dragItem = uveStore.dragItem(); + + if (!dragItem && types.includes('dotcms/item')) { + return; + } + + uveStore.setEditorState(EDITOR_STATE.DRAGGING); + contentWindow?.postMessage( + { + name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS + }, + host + ); + + if (dragItem) { + return; + } + + uveStore.setEditorDragItem(TEMPORAL_DRAG_ITEM); + handlers.onDragEnter(event); + }); + + // Drag end + fromEvent(this.window, 'dragend') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter((event: DragEvent) => event.dataTransfer?.dropEffect === 'none') + ) + .subscribe(() => { + handlers.onDragEnd(); + }); + + // Drag over + fromEvent(this.window, 'dragover') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter(() => !!uveStore.dragItem()) + ) + .subscribe((event: DragEvent) => { + event.preventDefault(); + + if (!iframe?.nativeElement) { + return; + } + + const iframeRect = iframe.nativeElement.getBoundingClientRect(); + const isInsideIframe = + event.clientX > iframeRect.left && event.clientX < iframeRect.right; + + if (!isInsideIframe) { + uveStore.setEditorState(EDITOR_STATE.DRAGGING); + return; + } + + let direction: 'up' | 'down' | undefined; + + if ( + event.clientY > iframeRect.top && + event.clientY < iframeRect.top + IFRAME_SCROLL_ZONE + ) { + direction = 'up'; + } + + if ( + event.clientY > iframeRect.bottom - IFRAME_SCROLL_ZONE && + event.clientY <= iframeRect.bottom + ) { + direction = 'down'; + } + + if (!direction) { + uveStore.setEditorState(EDITOR_STATE.DRAGGING); + return; + } + + uveStore.updateEditorScrollDragState(); + + contentWindow?.postMessage( + { name: __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME, direction }, + host + ); + + handlers.onDragOver(event); + }); + + // Drag leave + fromEvent(this.window, 'dragleave') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter((event: DragEvent) => !event.relatedTarget) + ) + .subscribe(() => { + handlers.onDragLeave(); + }); + + // Drop + fromEvent(this.window, 'drop') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event: DragEvent) => { + handlers.onDrop(event); + }); + } +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts new file mode 100644 index 000000000000..9495afcc0ff6 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts @@ -0,0 +1,190 @@ +import { fromEvent } from 'rxjs'; + +import { Injectable, signal, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { WINDOW } from '@dotcms/utils'; + +export interface ZoomCanvasStyles { + outer: { + width: string; + height: string; + }; + inner: { + width: string; + height: string; + transform: string; + transformOrigin: string; + }; +} + +@Injectable() +export class DotUveZoomService { + private readonly window = inject(WINDOW); + private readonly destroyRef = inject(DestroyRef); + + readonly $zoomLevel = signal(1); + readonly $isZoomMode = signal(false); + readonly $iframeDocHeight = signal(0); + + #zoomModeResetTimeout: ReturnType | null = null; + #gestureStartZoom = 1; + + readonly $canvasOuterStyles = computed(() => { + const zoom = this.$zoomLevel(); + const height = this.$iframeDocHeight() || 800; + return { + width: `${1520 * zoom}px`, + height: `${height * zoom}px` + }; + }); + + readonly $canvasInnerStyles = computed(() => { + const zoom = this.$zoomLevel(); + const height = this.$iframeDocHeight() || 800; + return { + width: `1520px`, + height: `${height}px`, + transform: `scale(${zoom})`, + transformOrigin: 'top left' + }; + }); + + zoomIn(): void { + this.$zoomLevel.set(Math.max(0.1, Math.min(3, this.$zoomLevel() + 0.1))); + } + + zoomOut(): void { + this.$zoomLevel.set(Math.max(0.1, Math.min(3, this.$zoomLevel() - 0.1))); + } + + resetZoom(): void { + this.$zoomLevel.set(1); + } + + zoomLabel(): string { + return `${Math.round(this.$zoomLevel() * 100)}%`; + } + + setupZoomInteractions( + zoomContainer: HTMLElement, + editorContent: HTMLElement, + onClampScroll: () => void + ): void { + type GestureLikeEvent = Event & { + scale?: number; + clientX: number; + clientY: number; + preventDefault: () => void; + }; + + const applyZoomFromWheel = (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) { + return; + } + + const rect = editorContent.getBoundingClientRect(); + const insideEditor = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!insideEditor) { + return; + } + + event.preventDefault(); + + this.$isZoomMode.set(true); + if (this.#zoomModeResetTimeout) { + clearTimeout(this.#zoomModeResetTimeout); + } + this.#zoomModeResetTimeout = setTimeout(() => this.$isZoomMode.set(false), 150); + + const delta = event.deltaY > 0 ? -0.1 : 0.1; + const newZoom = Math.max(0.1, Math.min(3, this.$zoomLevel() + delta)); + this.$zoomLevel.set(newZoom); + onClampScroll(); + }; + + // Zoom with mouse wheel on the canvas container + fromEvent(zoomContainer, 'wheel', { passive: false }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(applyZoomFromWheel); + + // Zoom with mouse wheel on the whole editor content area + fromEvent(editorContent, 'wheel', { passive: false }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(applyZoomFromWheel); + + // Trackpad pinch can still trigger browser zoom if the event originates from the iframe + fromEvent(this.window, 'wheel', { passive: false, capture: true } as AddEventListenerOptions) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(applyZoomFromWheel); + + // Safari trackpad pinch uses non-standard GestureEvents + fromEvent( + this.window, + 'gesturestart', + { passive: false, capture: true } as AddEventListenerOptions + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + const rect = editorContent.getBoundingClientRect(); + const insideEditor = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!insideEditor) { + return; + } + + event.preventDefault(); + this.$isZoomMode.set(true); + this.#gestureStartZoom = this.$zoomLevel(); + }); + + fromEvent( + this.window, + 'gesturechange', + { passive: false, capture: true } as AddEventListenerOptions + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + const rect = editorContent.getBoundingClientRect(); + const insideEditor = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!insideEditor) { + return; + } + + event.preventDefault(); + const scale = typeof event.scale === 'number' ? event.scale : 1; + const newZoom = Math.max(0.1, Math.min(3, this.#gestureStartZoom * scale)); + this.$zoomLevel.set(newZoom); + }); + + fromEvent( + this.window, + 'gestureend', + { passive: false, capture: true } as AddEventListenerOptions + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + event.preventDefault(); + this.$isZoomMode.set(false); + }); + } + + setIframeDocHeight(height: number): void { + this.$iframeDocHeight.set(height); + } +} + From 7fce1e86379d9ce9951239cb672f0d517feb5bd3 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:53:08 -0600 Subject: [PATCH 015/100] Refactor DotUveBridgeService: streamline imports and simplify sendMessageToIframe method signature --- .../services/dot-uve-bridge/dot-uve-bridge.service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts index b61a67dd311e..a45090cbdd4b 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts @@ -1,8 +1,11 @@ -import { Injectable, ElementRef, inject, signal, WritableSignal } from '@angular/core'; import { fromEvent, Observable } from 'rxjs'; + +import { Injectable, ElementRef, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + import { WINDOW } from '@dotcms/utils'; -import { DestroyRef } from '@angular/core'; + + import { PostMessage } from '../../shared/models'; import { DotUveZoomService } from '../dot-uve-zoom/dot-uve-zoom.service'; @@ -47,7 +50,7 @@ export class DotUveBridgeService { } } - sendMessageToIframe(message: unknown, host: string = '*'): void { + sendMessageToIframe(message: unknown, host = '*'): void { this.iframeElement?.contentWindow?.postMessage(message, host); } From a5d3e228390ef2bfeacd95dff9525bd2a9a8d47b Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:04:29 -0600 Subject: [PATCH 016/100] Enhance EmaPageDropzone component: add zoomLevel input and adjust positioning calculations for zoom functionality --- .../ema-page-dropzone.component.ts | 22 +++++++++++++------ .../edit-ema-editor.component.html | 1 + .../edit-ema-editor.component.ts | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/ema-page-dropzone/ema-page-dropzone.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/ema-page-dropzone/ema-page-dropzone.component.ts index befe6f2adc25..b7c88340bbf9 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/ema-page-dropzone/ema-page-dropzone.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/ema-page-dropzone/ema-page-dropzone.component.ts @@ -31,6 +31,7 @@ const POINTER_INITIAL_POSITION = { export class EmaPageDropzoneComponent { @Input() containers: Container[] = []; @Input() dragItem: EmaDragItem; + @Input() zoomLevel = 1; pointerPosition: Record = POINTER_INITIAL_POSITION; @@ -65,15 +66,16 @@ export class EmaPageDropzoneComponent { const isEmpty = empty === 'true'; const opacity = isEmpty ? '0.1' : '1'; - const height = isEmpty ? `${targetRect.height}px` : '3px'; + // Adjust coordinates for zoom level + const adjustedHeight = isEmpty ? targetRect.height / this.zoomLevel : 3; const top = this.getTop(isEmpty); this.pointerPosition = { - left: `${targetRect.left - parentRect.left}px`, - width: `${targetRect.width}px`, + left: `${(targetRect.left - parentRect.left) / this.zoomLevel}px`, + width: `${targetRect.width / this.zoomLevel}px`, opacity, top, - height + height: `${adjustedHeight}px` }; } @@ -102,12 +104,18 @@ export class EmaPageDropzoneComponent { private getTop(isEmpty: boolean): string { const { parentRect, targetRect, position } = this.$positionData(); + // Adjust coordinates for zoom level + // getBoundingClientRect() returns viewport coordinates, but we need + // coordinates relative to the transformed parent, so we adjust by zoom + const adjustedTop = (targetRect.top - parentRect.top) / this.zoomLevel; + const adjustedHeight = targetRect.height / this.zoomLevel; + if (isEmpty) { - return `${targetRect.top - parentRect.top}px`; + return `${adjustedTop}px`; } return position === 'before' - ? `${targetRect.top - parentRect.top}px` - : `${targetRect.top - parentRect.top + targetRect.height}px`; + ? `${adjustedTop}px` + : `${adjustedTop + adjustedHeight}px`; } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index 959ce99dc1e7..e414eea858b1 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -89,6 +89,7 @@ } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index 89ec4832f04c..95f2adb994e0 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -161,7 +161,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit private readonly dotWorkflowActionsFireService = inject(DotWorkflowActionsFireService); private readonly inlineEditingService = inject(InlineEditService); private readonly dotPageApiService = inject(DotPageApiService); - private readonly zoomService = inject(DotUveZoomService); + readonly zoomService = inject(DotUveZoomService); private readonly bridgeService = inject(DotUveBridgeService); private readonly actionsHandler = inject(DotUveActionsHandlerService); private readonly dragDropService = inject(DotUveDragDropService); From 33fbe2bb6f49875e12af81600a747bfe1a91dd36 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 07:56:49 -0600 Subject: [PATCH 017/100] Update DotUvePaletteComponent: change icon in tab header and remove unused expanded property from node definitions --- .../components/dot-uve-palette/dot-uve-palette.component.html | 2 +- .../components/dot-uve-palette/dot-uve-palette.component.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html index 211c74c8c6c5..de4f95de3d18 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html @@ -43,7 +43,7 @@
- +
@if ($activeTab() === 3) { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index 4a77b073420a..c472d5c07b7b 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -108,7 +108,6 @@ export class DotUvePaletteComponent { return { key: `row-${rowIndex}-column-${columnIndex}`, label: `Column ${columnIndex + 1}`, - expanded: containerNodes.length > 0, children: containerNodes.length > 0 ? containerNodes : undefined }; }); @@ -116,7 +115,6 @@ export class DotUvePaletteComponent { return { key: `row-${rowIndex}`, label: `Row ${rowIndex + 1}`, - expanded: columnNodes.length > 0, children: columnNodes.length > 0 ? columnNodes : undefined }; }); From dfb46950ee5bd7ec96184616d93a8cf9aa3ab5aa Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:25:29 -0600 Subject: [PATCH 018/100] Refactor DotUvePaletteComponent: improve container label retrieval and enhance data structure for container information --- .../dot-uve-palette/dot-uve-palette.component.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index c472d5c07b7b..bec62143cb61 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -93,15 +93,20 @@ export class DotUvePaletteComponent { const columnNodes: TreeNode[] = (row.columns || []).map((column, columnIndex) => { const containerNodes: TreeNode[] = (column.containers || []).map((container, containerIndex) => { const containerData = containers[container.identifier]; - const containerLabel = containerData?.container?.friendlyName || - containerData?.container?.title || + const containerInfo = containerData?.container; + const containerLabel = containerInfo?.name || + containerInfo?.friendlyName || + containerInfo?.title || container.identifier || `Container ${containerIndex + 1}`; return { key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}`, label: containerLabel, - data: container + data: { + ...container, + containerInfo: containerInfo + } }; }); From 25cb61500cf345a7d91290398cec088d98e86bb4 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:31:20 -0600 Subject: [PATCH 019/100] Enhance DotUvePaletteComponent: add contentlet nodes to layout tree structure for improved data representation --- .../dot-uve-palette.component.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index bec62143cb61..4c76171b8c7f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -77,7 +77,7 @@ export class DotUvePaletteComponent { /** * Computed signal that transforms the page layout structure into TreeNode format - * for the PrimeNG Tree component. Structure: rows > columns > containers + * for the PrimeNG Tree component. Structure: rows > columns > containers > contentlets */ readonly $layoutTree = computed(() => { const pageResponse = this.uveStore.pageAPIResponse(); @@ -100,13 +100,26 @@ export class DotUvePaletteComponent { container.identifier || `Container ${containerIndex + 1}`; + // Get contentlets for this container using uuid from layout + const contentletUuid = `uuid-${container.uuid}`; + const contentlets = containerData?.contentlets?.[contentletUuid] || []; + + const contentletNodes: TreeNode[] = contentlets.map((contentlet, contentletIndex) => { + return { + key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}-contentlet-${contentletIndex}`, + label: contentlet.title || `Contentlet ${contentletIndex + 1}`, + data: contentlet + }; + }); + return { key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}`, label: containerLabel, data: { ...container, containerInfo: containerInfo - } + }, + children: contentletNodes.length > 0 ? contentletNodes : undefined }; }); From 7f5076c86e27cc40d0968974da3218eb52bdf9b4 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:35:28 -0600 Subject: [PATCH 020/100] Update DotUvePaletteComponent: enhance tree display with overflow handling for improved layout management --- .../components/dot-uve-palette/dot-uve-palette.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html index de4f95de3d18..a80f16990430 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html @@ -49,7 +49,7 @@ @if ($activeTab() === 3) {
@if ($layoutTree().length > 0) { - + } @else {
Tree nodes: {{ $layoutTree().length }} From d9f9ad9afa48a4f6216ad1ee42a7c502750d8238 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:42:08 -0600 Subject: [PATCH 021/100] DotUveDragDropService: clean up --- .../dot-uve-drag-drop.service.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts index 4833255bd8e7..dfd29fb112d5 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts @@ -1,19 +1,18 @@ +import { fromEvent } from 'rxjs'; + import { Injectable, inject, DestroyRef, ElementRef } from '@angular/core'; -import { fromEvent, Observable } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + import { filter } from 'rxjs/operators'; -import { WINDOW, isEqual } from '@dotcms/utils'; + import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; -import { EDITOR_STATE } from '../../shared/enums'; +import { WINDOW } from '@dotcms/utils'; + + import { IFRAME_SCROLL_ZONE } from '../../shared/consts'; -import { PostMessage } from '../../shared/models'; +import { EDITOR_STATE } from '../../shared/enums'; import { UVEStore } from '../../store/dot-uve.store'; import { TEMPORAL_DRAG_ITEM, getDragItemData } from '../../utils'; -import { - ClientContentletArea, - Container, - EmaDragItem -} from '../../edit-ema-editor/components/ema-page-dropzone/types'; export interface DragDropHandlers { onDrop: (event: DragEvent) => void; From b8c3dedc166f1f263c6c6231d9b333bc35e7ab9b Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:47:15 -0600 Subject: [PATCH 022/100] Refactor DotUveActionsHandlerService: streamline imports and remove unused service injections for improved code clarity --- .../dot-uve-actions-handler.service.ts | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts index 68f6e1c957fc..af8b4a7876b3 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts @@ -1,49 +1,45 @@ -import { Injectable, inject } from '@angular/core'; -import { EMPTY, Observable, of } from 'rxjs'; -import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { tapResponse } from '@ngrx/operators'; +import { Observable, of } from 'rxjs'; + +import { Injectable, inject } from '@angular/core'; + import { MessageService } from 'primeng/api'; + +import { switchMap, take, tap } from 'rxjs/operators'; + + import { - DotCopyContentService, - DotHttpErrorManagerService, DotMessageService, - DotContentletService } from '@dotcms/data-access'; -import { - DotCMSContentlet, - DotLanguage, - DotTreeNode -} from '@dotcms/dotcms-models'; +import { DotCMSContentlet, DotTreeNode } from '@dotcms/dotcms-models'; import { DotCMSInlineEditingPayload, DotCMSInlineEditingType, - DotCMSPage, DotCMSUVEAction } from '@dotcms/types'; import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; import { DotCopyContentModalService } from '@dotcms/ui'; -import { isEqual } from '@dotcms/utils'; import { StyleEditorFormSchema } from '@dotcms/uve'; -import { DotPageApiService } from '../dot-page-api.service'; -import { InlineEditService } from '../inline-edit/inline-edit.service'; -import { UVEStore } from '../../store/dot-uve.store'; -import { EDITOR_STATE, NG_CUSTOM_EVENTS, UVE_STATUS } from '../../shared/enums'; -import { PostMessage, ReorderMenuPayload, SetUrlPayload } from '../../shared/models'; +import { DotBlockEditorSidebarComponent } from '../../components/dot-block-editor-sidebar/dot-block-editor-sidebar.component'; +import { DotEmaDialogComponent } from '../../components/dot-ema-dialog/dot-ema-dialog.component'; import { ClientContentletArea, Container, InlineEditingContentletDataset, UpdatedContentlet } from '../../edit-ema-editor/components/ema-page-dropzone/types'; +import { DEFAULT_PERSONA, PERSONA_KEY } from '../../shared/consts'; +import { EDITOR_STATE, UVE_STATUS } from '../../shared/enums'; +import { PostMessage, ReorderMenuPayload, SetUrlPayload } from '../../shared/models'; +import { UVEStore } from '../../store/dot-uve.store'; import { compareUrlPaths, convertClientParamsToPageParams, createReorderMenuURL } from '../../utils'; -import { DEFAULT_PERSONA, PERSONA_KEY } from '../../shared/consts'; -import { DotEmaDialogComponent } from '../../components/dot-ema-dialog/dot-ema-dialog.component'; -import { DotBlockEditorSidebarComponent } from '../../components/dot-block-editor-sidebar/dot-block-editor-sidebar.component'; +import { DotPageApiService } from '../dot-page-api.service'; +import { InlineEditService } from '../inline-edit/inline-edit.service'; export interface ActionsHandlerDependencies { uveStore: InstanceType; @@ -61,9 +57,6 @@ export class DotUveActionsHandlerService { private readonly dotMessageService = inject(DotMessageService); private readonly messageService = inject(MessageService); private readonly dotCopyContentModalService = inject(DotCopyContentModalService); - private readonly dotCopyContentService = inject(DotCopyContentService); - private readonly dotHttpErrorManagerService = inject(DotHttpErrorManagerService); - private readonly dotContentletService = inject(DotContentletService); handleAction( { action, payload }: PostMessage, @@ -72,7 +65,6 @@ export class DotUveActionsHandlerService { const { uveStore, dialog, - blockSidebar, inlineEditingService, dotPageApiService, contentWindow, @@ -214,7 +206,14 @@ export class DotUveActionsHandlerService { ) .subscribe(() => uveStore.reloadCurrentPage()); }, - [DotCMSUVEAction.CLIENT_READY]: (devConfig: any) => { + [DotCMSUVEAction.CLIENT_READY]: (devConfig: { + graphql: { + query: string; + variables: Record; + }; + params: Record; + query: string; + }) => { const isClientReady = uveStore.isClientReady(); if (isClientReady) { From 6f3469ced89ed1bf699d9c37f7df165e5fad524e Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 10:52:51 -0600 Subject: [PATCH 023/100] Enhance DotUvePaletteComponent and EditEmaEditor: add node selection handling to scroll to corresponding elements in the editor, improving user navigation experience. --- .../dot-uve-palette.component.html | 6 ++- .../dot-uve-palette.component.ts | 49 ++++++++++++++++-- .../edit-ema-editor.component.html | 1 + .../edit-ema-editor.component.ts | 50 ++++++++++++++++++- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html index a80f16990430..df4019ac3b07 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html @@ -49,7 +49,11 @@ @if ($activeTab() === 3) {
@if ($layoutTree().length > 0) { - + } @else {
Tree nodes: {{ $layoutTree().length }} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index 4c76171b8c7f..a578a9809f65 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, EventEmitter, inject, inp import { TreeNode } from 'primeng/api'; import { TabViewChangeEvent, TabViewModule } from 'primeng/tabview'; import { TooltipModule } from 'primeng/tooltip'; -import { TreeModule } from 'primeng/tree'; +import { TreeModule, TreeNodeSelectEvent } from 'primeng/tree'; import { DEFAULT_VARIANT_ID } from '@dotcms/dotcms-models'; import { StyleEditorFormSchema } from '@dotcms/uve'; @@ -71,6 +71,11 @@ export class DotUvePaletteComponent { */ @Output() onTabChange = new EventEmitter(); + /** + * Emits when a tree node is selected to scroll to the corresponding element. + */ + @Output() onNodeSelect = new EventEmitter<{ selector: string; type: string }>(); + protected readonly uveStore = inject(UVEStore); protected readonly TABS_MAP = UVE_PALETTE_TABS; protected readonly DotUVEPaletteListTypes = DotUVEPaletteListTypes; @@ -108,16 +113,24 @@ export class DotUvePaletteComponent { return { key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}-contentlet-${contentletIndex}`, label: contentlet.title || `Contentlet ${contentletIndex + 1}`, - data: contentlet + selectable: false, + data: { + ...contentlet, + type: 'contentlet', + selector: `[data-dot-identifier="${contentlet.identifier}"]` + } }; }); return { key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}`, label: containerLabel, + selectable: false, data: { ...container, - containerInfo: containerInfo + containerInfo: containerInfo, + type: 'container', + selector: `[data-dot-identifier="${container.identifier}"][data-dot-uuid="${container.uuid}"]` }, children: contentletNodes.length > 0 ? contentletNodes : undefined }; @@ -126,6 +139,7 @@ export class DotUvePaletteComponent { return { key: `row-${rowIndex}-column-${columnIndex}`, label: `Column ${columnIndex + 1}`, + selectable: false, children: containerNodes.length > 0 ? containerNodes : undefined }; }); @@ -133,6 +147,11 @@ export class DotUvePaletteComponent { return { key: `row-${rowIndex}`, label: `Row ${rowIndex + 1}`, + selectable: true, + data: { + type: 'row', + selector: `#section-${rowIndex + 1}` + }, children: columnNodes.length > 0 ? columnNodes : undefined }; }); @@ -148,4 +167,28 @@ export class DotUvePaletteComponent { protected handleTabChange(event: TabViewChangeEvent) { this.onTabChange.emit(event.index); } + + /** + * Handles tree node selection and emits event to scroll to the corresponding element. + * Only row nodes are selectable. + * + * @param event PrimeNG tree node select event + */ + protected handleNodeSelect(event: TreeNodeSelectEvent): void { + const node = event.node; + if (!node?.data || node.data.type !== 'row') { + return; + } + + const selector = node.data.selector; + if (!selector) { + return; + } + + // Emit event to parent component to handle scrolling + this.onNodeSelect.emit({ + selector: selector, + type: node.data.type + }); + } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index e414eea858b1..d6a1a52509ff 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -24,6 +24,7 @@ [showStyleEditorTab]="uveStore.$isStyleEditorEnabled()" [styleSchema]="uveStore.$styleSchema()" (onTabChange)="handleTabChange($event)" + (onNodeSelect)="handlePaletteNodeSelect($event)" data-testId="palette" /> } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index 95f2adb994e0..e022b262298f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -1217,7 +1217,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit }); } - protected handleTabChange(tab: UVE_PALETTE_TABS): void { this.uveStore.setPaletteTab(tab); } @@ -1238,4 +1237,53 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit break; } } + + /** + * Handles palette node selection and scrolls the editor-content to the corresponding element. + * + * @param event Event containing selector and type of the selected node + */ + protected handlePaletteNodeSelect(event: { selector: string; type: string }): void { + const iframeElement = this.iframe?.nativeElement; + const editorContentElement = this.editorContent?.nativeElement; + + if (!iframeElement || !editorContentElement) { + return; + } + + // Get the iframe document + let iframeDoc: Document | null = null; + try { + iframeDoc = iframeElement.contentDocument; + } catch { + // Cross-origin iframe, cannot access document + return; + } + + if (!iframeDoc) { + return; + } + + // Find the element in the iframe + const element = iframeDoc.querySelector(event.selector); + if (!element) { + return; + } + + const htmlElement = element as HTMLElement; + + // Use getBoundingClientRect() which accounts for all transforms including zoom + const elementRect = htmlElement.getBoundingClientRect(); + + // elementRect.top gives us the element's position in viewport coordinates + // Use it directly as the scroll position + const scrollTop = elementRect.top; + + // Scroll the editor-content smoothly + editorContentElement.scrollTo({ + top: Math.max(0, scrollTop), + left: 0, + behavior: 'smooth' + }); + } } From db80e44556bde92e5f0ae8b88e2009cdde4a8e6d Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:01:41 -0600 Subject: [PATCH 024/100] Adjust scroll position calculation in EditEmaEditor to account for zoom levels, enhancing the accuracy of element positioning in the editor. --- .../src/lib/edit-ema-editor/edit-ema-editor.component.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index e022b262298f..3ddc520af42a 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -1274,10 +1274,11 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit // Use getBoundingClientRect() which accounts for all transforms including zoom const elementRect = htmlElement.getBoundingClientRect(); + const zoomLevel = this.zoomService.$zoomLevel(); - // elementRect.top gives us the element's position in viewport coordinates - // Use it directly as the scroll position - const scrollTop = elementRect.top; + // elementRect.top works correctly at 100% zoom (zoomLevel = 1) + // For other zoom levels, convert from scaled to unscaled coordinates + const scrollTop = elementRect.top * zoomLevel; // Scroll the editor-content smoothly editorContentElement.scrollTo({ From 7a4dd9bd1949456b744749503aa9b01e6a62ed20 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:31:05 -0600 Subject: [PATCH 025/100] Enhance DotUvePaletteComponent: implement drag-and-drop functionality for tree nodes, allowing users to reorder rows within the layout. Add event handling for node selection and drop validation to improve user interaction and layout management. --- .../dot-uve-palette.component.html | 4 + .../dot-uve-palette.component.ts | 98 ++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html index df4019ac3b07..b36fea2fc7d9 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.html @@ -52,7 +52,11 @@ } @else {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index a578a9809f65..371d5fa3a38b 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -1,9 +1,9 @@ import { ChangeDetectionStrategy, Component, computed, EventEmitter, inject, input, Output } from '@angular/core'; -import { TreeNode } from 'primeng/api'; +import { TreeNode, TreeDragDropService } from 'primeng/api'; import { TabViewChangeEvent, TabViewModule } from 'primeng/tabview'; import { TooltipModule } from 'primeng/tooltip'; -import { TreeModule, TreeNodeSelectEvent } from 'primeng/tree'; +import { TreeModule, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree'; import { DEFAULT_VARIANT_ID } from '@dotcms/dotcms-models'; import { StyleEditorFormSchema } from '@dotcms/uve'; @@ -31,6 +31,7 @@ import { UVE_PALETTE_TABS } from '../../../store/features/editor/models'; DotUveStyleEditorFormComponent, TreeModule ], + providers: [TreeDragDropService], templateUrl: './dot-uve-palette.component.html', styleUrl: './dot-uve-palette.component.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -114,6 +115,7 @@ export class DotUvePaletteComponent { key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}-contentlet-${contentletIndex}`, label: contentlet.title || `Contentlet ${contentletIndex + 1}`, selectable: false, + draggable: false, data: { ...contentlet, type: 'contentlet', @@ -126,6 +128,7 @@ export class DotUvePaletteComponent { key: `row-${rowIndex}-column-${columnIndex}-container-${containerIndex}`, label: containerLabel, selectable: false, + draggable: false, data: { ...container, containerInfo: containerInfo, @@ -140,6 +143,7 @@ export class DotUvePaletteComponent { key: `row-${rowIndex}-column-${columnIndex}`, label: `Column ${columnIndex + 1}`, selectable: false, + draggable: false, children: containerNodes.length > 0 ? containerNodes : undefined }; }); @@ -148,9 +152,12 @@ export class DotUvePaletteComponent { key: `row-${rowIndex}`, label: `Row ${rowIndex + 1}`, selectable: true, + draggable: true, + droppable: false, data: { type: 'row', - selector: `#section-${rowIndex + 1}` + selector: `#section-${rowIndex + 1}`, + rowIndex }, children: columnNodes.length > 0 ? columnNodes : undefined }; @@ -191,4 +198,89 @@ export class DotUvePaletteComponent { type: node.data.type }); } + + /** + * Handles tree node drop event to reorder rows. + * Only allows reordering of row nodes at the root level. + * + * @param event PrimeNG tree node drop event + */ + protected handleNodeDrop(event: TreeNodeDropEvent): void { + const { dragNode, dropNode, index } = event; + + // Only allow reordering if the dragged node is a row + if (!dragNode?.data || dragNode.data.type !== 'row') { + if (event.accept) { + // Reject the drop + return; + } + return; + } + + // Only allow dropping at root level (dropNode is null or another row) + if (dropNode && dropNode.data?.type !== 'row') { + if (event.accept) { + // Reject the drop + return; + } + return; + } + + const pageResponse = this.uveStore.pageAPIResponse(); + if (!pageResponse?.layout?.body?.rows) { + return; + } + + const rows = [...pageResponse.layout.body.rows]; + const currentTreeNodes = this.$layoutTree(); + + // Find the drag index in the current tree + const dragIndex = currentTreeNodes.findIndex(node => node.key === dragNode.key); + + // Determine drop index + let dropIndex: number; + if (dropNode === null || dropNode === undefined) { + // Dropping at root level - use the provided index + dropIndex = index !== undefined ? index : rows.length; + } else { + // Dropping on another row - find its index + const targetIndex = currentTreeNodes.findIndex(node => node.key === dropNode.key); + dropIndex = targetIndex >= 0 ? targetIndex : rows.length; + } + + // Validate indices + if (dragIndex < 0 || dragIndex >= rows.length) { + return; + } + if (dropIndex < 0) { + dropIndex = 0; + } + if (dropIndex > rows.length) { + dropIndex = rows.length; + } + + // Prevent no-op operations + if (dragIndex === dropIndex) { + if (event.accept) { + event.accept(); + } + return; + } + + // Calculate the new sorted rows array based on the drop operation + const sortedRows = [...rows]; + const [movedRow] = sortedRows.splice(dragIndex, 1); + // Adjust drop index if we removed an item before it + const adjustedDropIndex = dragIndex < dropIndex ? dropIndex - 1 : dropIndex; + sortedRows.splice(adjustedDropIndex, 0, movedRow); + + // Log the new sorted version + // eslint-disable-next-line no-console + console.log('Sorted rows after drop:', sortedRows); + + // Accept the drop if validateDrop is enabled + if (event.accept) { + event.accept(); + } + } } From 9336d550c35b637ead809097a19e3e6e86b9295e Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:21:20 -0600 Subject: [PATCH 026/100] Integrate DotPageLayoutService into DotUvePaletteComponent and withSave function: implement updateRows method for saving reordered layout rows, enhancing layout management and state handling in the editor. --- .../dot-uve-palette.component.ts | 10 ++- .../store/features/editor/save/withSave.ts | 77 ++++++++++++++++++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index 371d5fa3a38b..f20f4df6f124 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -5,6 +5,7 @@ import { TabViewChangeEvent, TabViewModule } from 'primeng/tabview'; import { TooltipModule } from 'primeng/tooltip'; import { TreeModule, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree'; +import { DotPageLayoutService } from '@dotcms/data-access'; import { DEFAULT_VARIANT_ID } from '@dotcms/dotcms-models'; import { StyleEditorFormSchema } from '@dotcms/uve'; @@ -81,6 +82,9 @@ export class DotUvePaletteComponent { protected readonly TABS_MAP = UVE_PALETTE_TABS; protected readonly DotUVEPaletteListTypes = DotUVEPaletteListTypes; + protected readonly dotPageLayoutService = inject(DotPageLayoutService); + + /** * Computed signal that transforms the page layout structure into TreeNode format * for the PrimeNG Tree component. Structure: rows > columns > containers > contentlets @@ -278,9 +282,7 @@ export class DotUvePaletteComponent { // eslint-disable-next-line no-console console.log('Sorted rows after drop:', sortedRows); - // Accept the drop if validateDrop is enabled - if (event.accept) { - event.accept(); - } + this.uveStore.updateRows(sortedRows); + } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts index 9b1363a6c075..0e45d103cab1 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts @@ -7,7 +7,9 @@ import { inject } from '@angular/core'; import { catchError, map, switchMap, tap } from 'rxjs/operators'; -import { DotCMSPageAsset } from '@dotcms/types'; +import { DotPageLayoutService } from '@dotcms/data-access'; +import { DotPageRender } from '@dotcms/dotcms-models'; +import { DotCMSPageAsset, DotPageAssetLayoutRow } from '@dotcms/types'; import { DotPageApiService } from '../../../../services/dot-page-api.service'; import { UVE_STATUS } from '../../../../shared/enums'; @@ -29,6 +31,7 @@ export function withSave() { withLoad(), withMethods((store) => { const dotPageApiService = inject(DotPageApiService); + const dotPageLayoutService = inject(DotPageLayoutService); return { savePage: rxMethod( @@ -86,6 +89,78 @@ export function withSave() { ); }) ) + ), + updateRows: rxMethod( + pipe( + tap(() => { + patchState(store, { + status: UVE_STATUS.LOADING + }); + }), + switchMap((sortedRows) => { + const pageResponse = store.pageAPIResponse(); + + return dotPageLayoutService.save(pageResponse.page.identifier, { + layout: { + ...pageResponse.layout, + body: { + ...pageResponse.layout.body, + rows: sortedRows.map((row) => { + return { + ...row, + columns: row.columns.map((column) => { + return { + leftOffset: column.leftOffset, + styleClass: column.styleClass, + width: column.width, + containers: column.containers + }; + }) + }; + }) + } + }, + themeId: pageResponse.template.theme, + title: null + }).pipe( + tapResponse({ + next: (pageRender: DotPageRender) => { + patchState(store, { + status: UVE_STATUS.LOADED, + pageAPIResponse: { + ...pageResponse, + page: { + ...pageResponse.page, + rendered: pageRender.page.rendered + }, + layout: { + ...pageResponse.layout, + body: { + ...pageResponse.layout.body, + rows: sortedRows + } + } + } + }); + }, + error: (e) => { + console.error(e); + patchState(store, { + status: UVE_STATUS.ERROR + }); + } + }) + ); + }), + catchError((e) => { + console.error(e); + patchState(store, { + status: UVE_STATUS.ERROR + }); + + return EMPTY; + }) + ) ) }; }) From bfeae758b1748375df35e1b4af7977d65fdf8123 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:53:00 -0600 Subject: [PATCH 027/100] Update _tree.scss and DotUvePaletteComponent: adjust tree node margins and paddings for improved layout, and modify row label to use styleClass for better representation. --- .../dotcms-scss/angular/dotcms-theme/components/_tree.scss | 6 +++--- .../components/dot-uve-palette/dot-uve-palette.component.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss index 1461a513433d..477dc3a18540 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss @@ -10,16 +10,16 @@ .p-tree-container { .p-treenode { padding: 0; - margin: 0; + margin: 2px 0; outline: 0; .p-treenode-content { border-radius: $border-radius-xs; transition: none; - padding: $spacing-0 $spacing-1; + padding: 0 $spacing-0; .p-tree-toggler { - margin-right: $spacing-1; + margin-right: $spacing-0; width: $spacing-5; height: $spacing-5; color: $black; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts index f20f4df6f124..b7009e70f212 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts @@ -154,7 +154,7 @@ export class DotUvePaletteComponent { return { key: `row-${rowIndex}`, - label: `Row ${rowIndex + 1}`, + label: row.styleClass, // TODO: we need a propper label for the row selectable: true, draggable: true, droppable: false, From 5b86cfdd5e9812810291678bf34b89a85718e4d8 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:36:45 -0600 Subject: [PATCH 028/100] Enhance EditEmaEditor: implement dynamic contentlet form generation with validation, integrate form submission handling, and update layout for improved user interaction. Adjust styles for right sidebar and contentlet tools to support new features. --- .../dot-uve-contentlet-tools.component.scss | 2 +- .../dot-uve-contentlet-tools.component.ts | 17 +- .../dot-uve-palette-list.component.ts | 2 +- .../edit-ema-editor.component.html | 90 +++++++- .../edit-ema-editor.component.scss | 9 +- .../edit-ema-editor.component.ts | 212 +++++++++++++++++- .../portlet/src/lib/store/dot-uve.store.ts | 9 +- .../edit-ema/portlet/src/lib/store/models.ts | 3 +- 8 files changed, 323 insertions(+), 21 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss index f58da024d3f0..5fe999313a0c 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss @@ -35,7 +35,7 @@ .bounds { position: absolute; - pointer-events: none; + pointer-events: all; outline: solid 2px $color-palette-primary-500; background-color: transparent; container-type: inline-size; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts index 6562884f0bb4..8192ab923cc3 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts @@ -20,7 +20,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { ActionPayload, ContentletPayload, VTLFile } from '../../../shared/models'; +import { ActionPayload, ClientData, ContentletPayload, VTLFile } from '../../../shared/models'; import { ContentletArea } from '../ema-page-dropzone/types'; /** @@ -32,7 +32,10 @@ import { ContentletArea } from '../ema-page-dropzone/types'; imports: [NgStyle, ButtonModule, MenuModule, JsonPipe, TooltipModule, DotMessagePipe], templateUrl: './dot-uve-contentlet-tools.component.html', styleUrls: ['./dot-uve-contentlet-tools.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '(click)': 'handleClick()' + } }) export class DotUveContentletToolsComponent { readonly #dotMessageService = inject(DotMessageService); @@ -85,6 +88,9 @@ export class DotUveContentletToolsComponent { type: 'content' | 'form' | 'widget'; payload: ActionPayload; }>(); + + + readonly outputSelectedContentlet = output>(); /** * Emitted when the contentlet is selected from the tools (for example, via a drag handle). */ @@ -238,4 +244,11 @@ export class DotUveContentletToolsComponent { this.menu()?.hide(); this.menuVTL()?.hide(); } + + protected handleClick(): void { + this.outputSelectedContentlet.emit({ + container: this.contentletArea()?.payload.container, + contentlet: this.contentletArea()?.payload.contentlet + }); + } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-palette-list/dot-uve-palette-list.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-palette-list/dot-uve-palette-list.component.ts index 001b588d6327..a4fd13a49f1c 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-palette-list/dot-uve-palette-list.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-palette-list/dot-uve-palette-list.component.ts @@ -100,7 +100,7 @@ const DEBOUNCE_TIME = 300; DotMessagePipe, ContextMenuModule ], - providers: [DotPaletteListStore, DotESContentService], + providers: [DotESContentService], templateUrl: './dot-uve-palette-list.component.html', styleUrl: './dot-uve-palette-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index d6a1a52509ff..e23c60838605 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -58,8 +58,7 @@ [pointerEvents]="$iframePointerEvents()" [opacity]="$iframeOpacity()" #iframe - data-testId="iframe"> - + data-testId="iframe"> @if ($editorProps().progressBar) {
+ + @if ($editorProps().showDialogs) { { + if (!values) { + return []; + } + + return values + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + const [label, value] = line.split('|').map((s) => s.trim()); + return { + label: label || value || '', + value: value || label || '' + }; + }); + } + + /** + * Flattens the content type layout structure and filters for TextField fields only + */ + private getTextFieldFields( + layout: DotCMSContentTypeLayoutRow[] + ): Pick< + DotCMSContentTypeField, + 'name' | 'variable' | 'regexCheck' | 'dataType' | 'readOnly' | 'required' | 'clazz' | 'values' + >[] { + return layout + .flatMap((row) => row.columns ?? []) + .flatMap((column) => column.fields) + .filter( + (field) => + field.clazz === DotCMSClazzes.TEXT || + field.clazz === DotCMSClazzes.TEXTAREA || + field.clazz === DotCMSClazzes.CHECKBOX || + field.clazz === DotCMSClazzes.MULTI_SELECT || + field.clazz === DotCMSClazzes.RADIO || + field.clazz === DotCMSClazzes.SELECT + + ) + .map((field) => { + return { + name: field.name, + variable: field.variable, + regexCheck: field.regexCheck, + dataType: field.dataType, + readOnly: field.readOnly, + required: field.required, + clazz: field.clazz, + values: field.values + }; + }); + } + + protected readonly $selectedContentlet = computed(() => { + const { container, contentlet } = this.uveStore.selectedContentlet() ?? {}; + + const contentType = this.$contenttypes().find( + (ct) => ct.variable === contentlet?.contentType + ); + + const fields = contentType?.layout ? this.getTextFieldFields(contentType.layout) : []; + + // Parse values for each field + const fieldsWithOptions = fields.map((field) => ({ + ...field, + options: this.parseFieldValues(field.values) + })); + + return { container, contentlet, fields: fieldsWithOptions }; + }); private readonly dotMessageService = inject(DotMessageService); private readonly confirmationService = inject(ConfirmationService); private readonly messageService = inject(MessageService); @@ -167,8 +265,12 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit private readonly dragDropService = inject(DotUveDragDropService); readonly #destroyRef = inject(DestroyRef); readonly #dotAlertConfirmService = inject(DotAlertConfirmService); + readonly #fb = inject(FormBuilder); #iframeResizeObserver: ResizeObserver | null = null; + readonly #contentletForm = signal(null); + readonly $contentletForm = computed(() => this.#contentletForm()); + readonly host = '*'; readonly $ogTags: WritableSignal = signal(undefined); readonly $editorProps = this.uveStore.$editorProps; @@ -185,6 +287,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit readonly $isDragging = this.uveStore.$isDragging; readonly UVE_STATUS = UVE_STATUS; + readonly DotCMSClazzes = DotCMSClazzes; readonly $paletteClass = computed(() => { return this.$paletteOpen() ? PALETTE_CLASSES.OPEN : PALETTE_CLASSES.CLOSED; @@ -251,6 +354,94 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit ); }); + readonly $buildContentletFormEffect = effect(() => { + const { fields, container, contentlet } = this.$selectedContentlet(); + const selectedContentlet = this.uveStore.selectedContentlet(); + const pageAPIResponse = this.uveStore.pageAPIResponse(); + + if (!fields || fields.length === 0 || !selectedContentlet || !pageAPIResponse) { + this.#contentletForm.set(null); + return; + } + + // Get the full contentlet from pageAPIResponse using container identifier and uuid + let fullContentlet: DotCMSContentlet | undefined = contentlet as DotCMSContentlet; + if (container?.identifier && container?.uuid && contentlet?.identifier) { + const containerData = pageAPIResponse.containers?.[container.identifier]; + const contentletUuid = `uuid-${container.uuid}`; + const contentlets = containerData?.contentlets?.[contentletUuid] || []; + const foundContentlet = contentlets.find( + (c) => c.identifier === contentlet.identifier + ); + if (foundContentlet) { + fullContentlet = foundContentlet as DotCMSContentlet; + } + } + + const formControls: Record = {}; + + fields.forEach((field) => { + let fieldValue: string | string[] | boolean = fullContentlet?.[field.variable] ?? ''; + const validators = []; + + // Handle checkbox with multiple options - value should be an array + if (field.clazz === DotCMSClazzes.CHECKBOX && field.options && field.options.length > 0) { + // Convert string value to array if needed + if (typeof fieldValue === 'string' && fieldValue) { + fieldValue = fieldValue.split(',').map((v) => v.trim()); + } else if (!Array.isArray(fieldValue)) { + fieldValue = []; + } + } + + // Handle multi-select - value should be an array + if (field.clazz === DotCMSClazzes.MULTI_SELECT) { + if (typeof fieldValue === 'string' && fieldValue) { + fieldValue = fieldValue.split(',').map((v) => v.trim()); + } else if (!Array.isArray(fieldValue)) { + fieldValue = []; + } + } + + if (field.required) { + validators.push(Validators.required); + } + + if (field.regexCheck) { + try { + // Validate the regex pattern before using it + new RegExp(field.regexCheck); + validators.push(Validators.pattern(field.regexCheck)); + } catch (error) { + // Skip invalid regex patterns + console.warn( + `Invalid regex pattern for field ${field.variable}: ${field.regexCheck}`, + error + ); + } + } + + formControls[field.variable] = this.#fb.control( + fieldValue, + validators.length > 0 ? validators : null + ); + + if (field.readOnly) { + formControls[field.variable].disable(); + } + }); + + this.#contentletForm.set(this.#fb.group(formControls)); + }); + + protected onFormSubmit(): void { + const form = this.$contentletForm(); + if (form) { + // eslint-disable-next-line no-console + console.log('Form values:', form.value); + } + } + ngOnInit(): void { // Initialization happens in ngAfterViewInit when ViewChild references are available // This lifecycle hook satisfies OnInit interface requirement @@ -285,6 +476,12 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.setupDragDrop(); } + handleSelectedContentlet( + selectedContentlet: Pick + ): void { + this.uveStore.setSelectedContentlet(selectedContentlet); + } + private setupZoom(): void { const zoomContainer = this.zoomContainer?.nativeElement; const editorContent = this.editorContent?.nativeElement; @@ -293,10 +490,8 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit return; } - this.zoomService.setupZoomInteractions( - zoomContainer, - editorContent, - () => this.#clampScrollWithinBounds() + this.zoomService.setupZoomInteractions(zoomContainer, editorContent, () => + this.#clampScrollWithinBounds() ); } @@ -429,7 +624,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit } as unknown as { language: string; mode: string; inode: string; fieldName: string }); } - onIframePageLoad(): void { if (this.uveStore.state() === EDITOR_STATE.INLINE_EDITING) { this.inlineEditingService.initEditor(); @@ -443,7 +637,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.#clampScrollWithinBounds(); } - ngOnDestroy(): void { this.#iframeResizeObserver?.disconnect(); this.#iframeResizeObserver = null; @@ -1143,7 +1336,6 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.loadPageAsset({ language_id: '1' }); } - #clientPayload() { const graphqlResponse = this.uveStore.$customGraphqlResponse(); @@ -1181,14 +1373,10 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.resetContentletArea(); } - - - protected handleSelectContent(contentlet: ContentletPayload): void { this.uveStore.setActiveContentlet(contentlet); } - #scrollToTopLeft(): void { const el = this.editorContent?.nativeElement; if (!el) { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts index e6debc393f8b..7ad91fc6fc56 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts @@ -15,6 +15,7 @@ import { DotUveViewParams, ShellProps, TranslateProps, UVEState } from './models import { UVE_FEATURE_FLAGS } from '../shared/consts'; import { UVE_STATUS } from '../shared/enums'; +import { ClientData } from '../shared/models'; import { getErrorPayload, getRequestHostName, normalizeQueryParams, sanitizeURL } from '../utils'; // Some properties can be computed @@ -30,7 +31,8 @@ const initialState: UVEState = { viewParams: null, status: UVE_STATUS.LOADING, isTraditionalPage: true, - isClientReady: false + isClientReady: false, + selectedContentlet: undefined }; export const UVEStore = signalStore( @@ -58,6 +60,11 @@ export const UVEStore = signalStore( ...viewParams } }); + }, + setSelectedContentlet(selectedContentlet: Pick) { + patchState(store, { + selectedContentlet + }); } }; }), diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts index d10bf8c1b033..c44a120d3ea5 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts @@ -9,7 +9,7 @@ import { DotCMSPage, DotCMSPageAsset } from '@dotcms/types'; import { InfoPage } from '@dotcms/ui'; import { UVE_STATUS } from '../shared/enums'; -import { DotPageAssetParams, NavigationBarItem } from '../shared/models'; +import { ClientData, DotPageAssetParams, NavigationBarItem } from '../shared/models'; export interface UVEState { languages: DotLanguage[]; @@ -24,6 +24,7 @@ export interface UVEState { isTraditionalPage: boolean; isClientReady: boolean; workflowActions?: DotCMSWorkflowAction[]; + selectedContentlet?: Pick; } export interface ShellProps { From 6a0b2fc4fdf7748ef4ab0c955d741d5f96452416 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:53:52 -0600 Subject: [PATCH 029/100] Enhance EditEmaEditor: add hidden inode field to form, implement loading state during submission, and update submit button behavior for improved user experience. --- .../edit-ema-editor.component.html | 6 +++++- .../edit-ema-editor.component.ts | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index e23c60838605..6d829a9774a6 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -108,6 +108,9 @@