Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { signal, WritableSignal } from '@angular/core';

import { DotSeoMetaTagsService, DotSeoMetaTagsUtilService } from '@dotcms/data-access';
import { SeoMetaTagsResult, SeoMetaTags } from '@dotcms/dotcms-models';
import * as uveInternal from '@dotcms/uve/internal';

import { DotUveIframeComponent } from './dot-uve-iframe.component';

import { InlineEditService } from '../../../services/inline-edit/inline-edit.service';
import { UVEStore } from '../../../store/dot-uve.store';
import { PageType } from '../../../store/models';
import { IframeAccessMode, PageType } from '../../../store/models';
import { SDK_EDITOR_SCRIPT_SOURCE } from '../../../utils/ema-legacy-script-injection';

describe('DotUveIframeComponent', () => {
Expand All @@ -27,6 +28,9 @@ describe('DotUveIframeComponent', () => {
let editorEnableInlineEditSignal: WritableSignal<boolean>;
let iframeDocHeightSignal: WritableSignal<number>;
let legacyScriptInjectionEnabledSignal: WritableSignal<boolean>;
let iframeAccessModeSignal: WritableSignal<IframeAccessMode>;
let observeDocumentHeightSpy: jest.SpyInstance;
let destroySpy: jest.Mock;

const mockPageRender = '<html><head></head><body>Test Content</body></html>';
const mockSeoResults: SeoMetaTagsResult[] = [
Expand Down Expand Up @@ -65,6 +69,7 @@ describe('DotUveIframeComponent', () => {
$pageRender: pageRenderSignal,
editorEnableInlineEdit: editorEnableInlineEditSignal,
pageType: pageTypeSignal,
iframeAccessMode: iframeAccessModeSignal,
$viewIframeDocHeight: iframeDocHeightSignal,
$isEmaLegacyScriptInjectionEnabled: legacyScriptInjectionEnabledSignal,
setSeoData: jest.fn()
Expand All @@ -74,7 +79,13 @@ describe('DotUveIframeComponent', () => {
});

beforeEach(() => {
destroySpy = jest.fn();
observeDocumentHeightSpy = jest
.spyOn(uveInternal, 'observeDocumentHeight')
.mockReturnValue({ destroy: destroySpy });

pageTypeSignal = signal(PageType.HEADLESS);
iframeAccessModeSignal = signal(IframeAccessMode.CROSS_ORIGIN);
pageRenderSignal = signal(mockPageRender);
editorEnableInlineEditSignal = signal(false);
iframeDocHeightSignal = signal(0);
Expand All @@ -97,6 +108,10 @@ describe('DotUveIframeComponent', () => {
mockInlineEditService = spectator.inject(InlineEditService, true);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('Component Creation', () => {
it('should create', () => {
expect(component).toBeTruthy();
Expand Down Expand Up @@ -176,6 +191,70 @@ describe('DotUveIframeComponent', () => {
component.onIframeLoad();
expect(insertSpy).not.toHaveBeenCalled();
});

it('should emit iframe document height when the iframe document is accessible', () => {
iframeAccessModeSignal.set(IframeAccessMode.LOCAL);
const mockIframe = document.createElement('iframe');
const mockDoc = document.implementation.createHTMLDocument();
const mockWindow = {} as Window;

Object.defineProperty(mockIframe, 'contentDocument', {
value: mockDoc,
writable: true
});
Object.defineProperty(mockIframe, 'contentWindow', {
value: mockWindow,
writable: true
});

component.iframe = { nativeElement: mockIframe } as any;

const iframeDocHeightChangeSpy = jest.spyOn(component.iframeDocHeightChange, 'emit');

component.onIframeLoad();

expect(observeDocumentHeightSpy).toHaveBeenCalledWith(
expect.objectContaining({
documentRef: mockDoc,
windowRef: mockWindow,
onHeightChange: expect.any(Function)
})
);

const onHeightChange = observeDocumentHeightSpy.mock.calls[0][0].onHeightChange;
onHeightChange(900);

expect(iframeDocHeightChangeSpy).toHaveBeenCalledWith(900);

component.ngOnDestroy();

expect(destroySpy).toHaveBeenCalled();
});

it('should skip local height tracking when iframe access is blocked', () => {
iframeAccessModeSignal.set(IframeAccessMode.LOCAL);
const mockIframe = document.createElement('iframe');

Object.defineProperty(mockIframe, 'contentDocument', {
configurable: true,
get: () => {
throw new DOMException('Blocked', 'SecurityError');
}
});

component.iframe = { nativeElement: mockIframe } as any;

expect(() => component.onIframeLoad()).not.toThrow();
expect(observeDocumentHeightSpy).not.toHaveBeenCalled();
});

it('should skip local height tracking when iframe access mode is cross-origin', () => {
iframeAccessModeSignal.set(IframeAccessMode.CROSS_ORIGIN);

component.onIframeLoad();

expect(observeDocumentHeightSpy).not.toHaveBeenCalled();
});
});

describe('onIframeLoad - TRADITIONAL page type', () => {
Expand All @@ -185,6 +264,7 @@ describe('DotUveIframeComponent', () => {

beforeEach(() => {
pageTypeSignal.set(PageType.TRADITIONAL);
iframeAccessModeSignal.set(IframeAccessMode.LOCAL);

// Create mock iframe with contentDocument and contentWindow
mockIframe = document.createElement('iframe');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Component,
DestroyRef,
ElementRef,
OnDestroy,
ViewChild,
effect,
inject,
Expand All @@ -17,10 +18,11 @@ import { filter, take, takeUntil } from 'rxjs/operators';

import { DotSeoMetaTagsService, DotSeoMetaTagsUtilService } from '@dotcms/data-access';
import { SafeUrlPipe } from '@dotcms/ui';
import { DocumentHeightObserverHandle, observeDocumentHeight } from '@dotcms/uve/internal';

import { InlineEditService } from '../../../services/inline-edit/inline-edit.service';
import { UVEStore } from '../../../store/dot-uve.store';
import { PageType } from '../../../store/models';
import { IframeAccessMode, PageType } from '../../../store/models';
import { addEditorPageScript } from '../../../utils/ema-legacy-script-injection';

/**
Expand All @@ -38,7 +40,7 @@ import { addEditorPageScript } from '../../../utils/ema-legacy-script-injection'
imports: [SafeUrlPipe],
host: { class: 'block relative' }
})
export class DotUveIframeComponent {
export class DotUveIframeComponent implements OnDestroy {
/**
* Reference to the iframe element.
* @type {ElementRef<HTMLIFrameElement>}
Expand Down Expand Up @@ -70,6 +72,7 @@ export class DotUveIframeComponent {
private readonly dotSeoMetaTagsUtilService = inject(DotSeoMetaTagsUtilService);
private readonly inlineEditingService = inject(InlineEditService);
private readonly destroyRef = inject(DestroyRef);
private iframeHeightObserverHandle: DocumentHeightObserverHandle | null = null;

/**
* Current height of the iframe document content.
Expand Down Expand Up @@ -136,6 +139,7 @@ export class DotUveIframeComponent {
*/
onIframeLoad(): void {
if (this.uveStore.pageType() === PageType.HEADLESS) {
this.startIframeHeightTracking();
this.load.emit();
return;
}
Expand Down Expand Up @@ -173,6 +177,7 @@ export class DotUveIframeComponent {
doc.close();

this.handleInlineScripts(enableInlineEdit);
this.startIframeHeightTracking();
}

/**
Expand Down Expand Up @@ -238,4 +243,49 @@ export class DotUveIframeComponent {
this.uveStore.setSeoData({ ogTags, ogTagsResults: results });
});
}

ngOnDestroy(): void {
this.stopIframeHeightTracking();
}

private startIframeHeightTracking(): void {
this.stopIframeHeightTracking();

if (this.uveStore.iframeAccessMode() !== IframeAccessMode.LOCAL) {
return;
}

const iframeElement = this.iframe?.nativeElement;

if (!iframeElement) {
return;
}

let doc: Document | null = null;
let win: Window | null = null;

try {
doc = iframeElement.contentDocument;
win = iframeElement.contentWindow;
} catch {
return;
}

if (!doc || !win) {
return;
}

this.iframeHeightObserverHandle = observeDocumentHeight({
documentRef: doc,
windowRef: win,
onHeightChange: (height) => {
this.iframeDocHeightChange.emit(height);
}
});
}

private stopIframeHeightTracking(): void {
this.iframeHeightObserverHandle?.destroy();
this.iframeHeightObserverHandle = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import { DotcmsConfigService, DotcmsEventsService, LoginService } from '@dotcms/
import { DEFAULT_VARIANT_ID, FeaturedFlags } from '@dotcms/dotcms-models';
import { DotResultsSeoToolComponent } from '@dotcms/portlets/dot-ema/ui';
import { GlobalStore } from '@dotcms/store';
import { DotCMSURLContentMap, UVE_MODE } from '@dotcms/types';
import { DotCMSURLContentMap, DotCMSUVEAction, UVE_MODE } from '@dotcms/types';
import { DotCopyContentModalService, SafeUrlPipe } from '@dotcms/ui';
import { WINDOW } from '@dotcms/utils';
import {
Expand Down Expand Up @@ -109,6 +109,7 @@ import {
} from '../shared/mocks';
import { ActionPayload } from '../shared/models';
import { UVEStore } from '../store/dot-uve.store';
import { IframeAccessMode } from '../store/models';

global.URL.createObjectURL = jest.fn(
() => 'blob:http://localhost:3000/12345678-1234-1234-1234-123456789012'
Expand Down Expand Up @@ -481,6 +482,7 @@ describe('EditEmaEditorComponent', () => {
confirmationService = spectator.inject(ConfirmationService, true);
messageService = spectator.inject(MessageService, true);
addMessageSpy = jest.spyOn(messageService, 'add');
mockDotUveActionsHandlerService.handleAction.mockClear();

store.pageLoad({
clientHost: 'http://localhost:3000',
Expand Down Expand Up @@ -560,6 +562,58 @@ describe('EditEmaEditorComponent', () => {
expect(wrapper.classList).not.toContain('open');
});

it('should ignore iframe height postMessage when the iframe is locally accessible', () => {
const iframe = spectator.debugElement.query(By.css('[data-testId="iframe"]'));
const mockDoc = document.implementation.createHTMLDocument();

patchState(store, { iframeAccessMode: IframeAccessMode.LOCAL });

Object.defineProperty(iframe.nativeElement, 'contentDocument', {
configurable: true,
value: mockDoc
});

window.dispatchEvent(
new MessageEvent('message', {
data: {
action: DotCMSUVEAction.IFRAME_HEIGHT,
payload: { height: 321 }
}
})
);

expect(mockDotUveActionsHandlerService.handleAction).not.toHaveBeenCalled();
});

it('should handle iframe height postMessage when the iframe is cross-origin', () => {
const iframe = spectator.debugElement.query(By.css('[data-testId="iframe"]'));

patchState(store, { iframeAccessMode: IframeAccessMode.CROSS_ORIGIN });

Object.defineProperty(iframe.nativeElement, 'contentDocument', {
configurable: true,
get: () => {
throw new DOMException('Blocked', 'SecurityError');
}
});

const message = {
action: DotCMSUVEAction.IFRAME_HEIGHT,
payload: { height: 321 }
};

window.dispatchEvent(
new MessageEvent('message', {
data: message
})
);

expect(mockDotUveActionsHandlerService.handleAction).toHaveBeenCalledWith(
message,
expect.any(Object)
);
});

it('should have a toolbar', () => {
const toolbar = spectator.query('dot-uve-toolbar');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import {
VTLFile
} from '../shared/models';
import { UVEStore } from '../store/dot-uve.store';
import { PageType } from '../store/models';
import { IframeAccessMode, PageType } from '../store/models';
import {
TEMPORAL_DRAG_ITEM,
areContainersEquals,
Expand All @@ -113,6 +113,7 @@ import {
insertContentletInContainer,
shouldNavigate
} from '../utils';

// Message keys constants
const MESSAGE_KEY = {
DUPLICATE_CONTENT: {
Expand Down Expand Up @@ -495,6 +496,13 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit
.subscribe((event) => {
const data = event.data;
if (this.#isUvePostMessage(data)) {
if (
data.action === DotCMSUVEAction.IFRAME_HEIGHT &&
this.uveStore.iframeAccessMode() === IframeAccessMode.LOCAL
) {
return;
}

this.handleUveMessage(data);
if (data.action === DotCMSUVEAction.IFRAME_HEIGHT) {
this.#clampScrollWithinBounds();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export class DotUveActionsHandlerService {
/* Get page data - handled by bridge service */
},
[DotCMSUVEAction.IFRAME_HEIGHT]: (payload: { height: number }) => {
console.log('IFRAME_HEIGHT', payload.height);
uveStore.viewSetIframeDocHeight(payload.height);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { withPageApi } from './features/page-api/withPageApi';
import { withTrack } from './features/track/withTrack';
import { withUve } from './features/uve/withUve';
import { withWorkflow } from './features/workflow/withWorkflow';
import { Orientation, PageType, UVEState } from './models';
import { IframeAccessMode, Orientation, PageType, UVEState } from './models';

import {
DEFAULT_DEVICE,
Expand All @@ -33,6 +33,7 @@ const initialState: UVEState = {
pageParams: null,
pageLanguages: [],
pageType: PageType.TRADITIONAL,
iframeAccessMode: IframeAccessMode.LOCAL,
pageExperiment: null,
pageErrorCode: null,
// Workflow state (managed by withWorkflow)
Expand Down
Loading
Loading