diff --git a/editor/src/app/preview/preview.component.spec.ts b/editor/src/app/preview/preview.component.spec.ts new file mode 100644 index 00000000..b62d530d --- /dev/null +++ b/editor/src/app/preview/preview.component.spec.ts @@ -0,0 +1,126 @@ +import { fakeAsync, tick } from '@angular/core/testing'; +import { Store } from '@ngxs/store'; +import { of } from 'rxjs'; + +import { PreviewComponent } from './preview.component'; + +/** + * Covers `waitFullLoad`, which decides when a loaded preview document is + * ready for the editor services to attach to. Only the store's `isSetup` + * selection is used, so every other dependency is an empty stub. + */ +describe('PreviewComponent (waitFullLoad)', () => { + const EDITOR_URL = 'https://example.com/engine/editor/?section=works'; + + let component: PreviewComponent; + + beforeEach(() => { + const store = { select: () => of(false) } as unknown as Store; + component = new PreviewComponent( + {} as any, + store, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + }); + + function fakeDocument(href: string, bodyClass: string | null): Document { + return { + location: { href }, + body: bodyClass === null ? null : { className: bodyClass }, + } as unknown as Document; + } + + function fakeIframe(doc: Document | null) { + return { contentDocument: doc } as { contentDocument: Document | null }; + } + + function watch(iframe: { contentDocument: Document | null }) { + const result = { + emitted: [] as unknown[], + completed: false, + error: undefined as unknown, + }; + + (component as any).waitFullLoad(iframe).subscribe({ + next: (value: unknown) => result.emitted.push(value), + error: (error: unknown) => (result.error = error), + complete: () => (result.completed = true), + }); + + return result; + } + + it('emits the iframe once the body has Berta classes', fakeAsync(() => { + const iframe = fakeIframe(fakeDocument(EDITOR_URL, 'xContent-works')); + const result = watch(iframe); + + tick(100); + + expect(result.emitted).toEqual([iframe]); + expect(result.completed).toBeTrue(); + })); + + it('emits right away for the login page', fakeAsync(() => { + const iframe = fakeIframe( + fakeDocument('https://example.com/engine/', null), + ); + const result = watch(iframe); + + tick(100); + + expect(result.emitted).toEqual([iframe]); + })); + + it('keeps waiting while the document has no body yet', fakeAsync(() => { + const doc = fakeDocument(EDITOR_URL, null); + const iframe = fakeIframe(doc); + const result = watch(iframe); + + tick(500); + + expect(result.emitted).toEqual([]); + expect(result.completed).toBeFalse(); + + (doc as any).body = { className: 'xSectionType-default' }; + tick(100); + + expect(result.emitted).toEqual([iframe]); + })); + + it('errors once the body never appears', fakeAsync(() => { + const result = watch(fakeIframe(fakeDocument(EDITOR_URL, null))); + + tick(12000); + + expect(result.emitted).toEqual([]); + expect(result.error).toBeDefined(); + })); + + it('completes without emitting once a newer navigation replaces the document', fakeAsync(() => { + const iframe = fakeIframe(fakeDocument('about:blank', '')); + const result = watch(iframe); + + tick(100); + expect(result.completed).toBeFalse(); + + // A freshly committed document: nothing parsed yet. + iframe.contentDocument = fakeDocument(EDITOR_URL, null); + tick(100); + + expect(result.emitted).toEqual([]); + expect(result.completed).toBeTrue(); + + // Even once the new document is ready, it's left to its own `load`. + (iframe.contentDocument as any).body = { className: 'xContent-works' }; + tick(500); + + expect(result.emitted).toEqual([]); + })); +}); diff --git a/editor/src/app/preview/preview.component.ts b/editor/src/app/preview/preview.component.ts index defc3c61..de223184 100644 --- a/editor/src/app/preview/preview.component.ts +++ b/editor/src/app/preview/preview.component.ts @@ -410,7 +410,19 @@ export class PreviewComponent implements OnInit { observer.complete(); }); + // The document this `load` event is for. A later navigation replaces it + // with a new document that fires its own `load` (and wait), so this one + // must not resolve against it — the new document may not even have an + // element parsed yet. + const loadedDocument = iframe.contentDocument; + checkInterval = setInterval(() => { + if (iframe.contentDocument !== loadedDocument) { + clearInterval(checkInterval); + observer.complete(); + return; + } + const lastUrlPart = iframe.contentDocument?.location.href .replace(/\/$/, '') .split('/') @@ -438,12 +450,12 @@ export class PreviewComponent implements OnInit { return; } + // No body yet means the page is still being parsed. + const body = iframe.contentDocument.body; + if ( - iframe.contentDocument.body && - (iframe.contentDocument.body.classList.length === 0 || - !/(xLoginPageBody|xContent-|xSectionType-)/.test( - iframe.contentDocument.body.className, - )) + !body || + !/(xLoginPageBody|xContent-|xSectionType-)/.test(body.className) ) { lastError = 'Berta classes `xLoginPageBody` or `xContent-[]` or `xSectionType-` are missing from body element'; diff --git a/editor/src/app/rerender/common/page-layout.service.ts b/editor/src/app/rerender/common/page-layout.service.ts index d9068274..b6aded95 100644 --- a/editor/src/app/rerender/common/page-layout.service.ts +++ b/editor/src/app/rerender/common/page-layout.service.ts @@ -1,8 +1,5 @@ import { Injectable } from '@angular/core'; -import { - removeExtraAddBtnAndAddListeners, - replaceContent, -} from '../utilities/content'; +import { notifyEntriesRerendered, replaceContent } from '../utilities/content'; @Injectable({ providedIn: 'root', @@ -36,7 +33,7 @@ export class PageLayoutService { replaceContent(dom, 'pageEntries', viewData.entries); replaceContent(dom, 'portfolioThumbnails', viewData.portfolioThumbnails); - removeExtraAddBtnAndAddListeners(iframe); + notifyEntriesRerendered(iframe); } protected static handleIsResponsiveSetting( diff --git a/editor/src/app/rerender/default-template-rerender.service.ts b/editor/src/app/rerender/default-template-rerender.service.ts index 89bf2f0b..735a94bf 100644 --- a/editor/src/app/rerender/default-template-rerender.service.ts +++ b/editor/src/app/rerender/default-template-rerender.service.ts @@ -5,10 +5,7 @@ import { UpdateNavigationSiteSettingsAction } from '../sites/settings/site-setti import { HandleSiteTemplateSettingsAction } from '../sites/template-settings/site-template-settings.actions'; import { TemplateRerenderService } from './template-rerender.service'; import { SiteSettingChildrenHandler } from './types/components'; -import { - replaceContent, - removeExtraAddBtnAndAddListeners, -} from './utilities/content'; +import { notifyEntriesRerendered, replaceContent } from './utilities/content'; @Injectable({ providedIn: 'root', @@ -108,12 +105,12 @@ export class DefaultTemplateRerenderService extends TemplateRerenderService { replaceContent(dom, 'siteBanners', viewData.siteBanners); replaceContent(dom, 'sectionFooter', viewData.sectionFooter); - removeExtraAddBtnAndAddListeners(iframe); + notifyEntriesRerendered(iframe); } else if (action.settingGroup === 'pageHeading') { replaceContent(dom, 'siteHeader', viewData.siteHeader); } else if (action.settingGroup === 'entryLayout') { replaceContent(dom, 'pageEntries', viewData.entries); - removeExtraAddBtnAndAddListeners(iframe); + notifyEntriesRerendered(iframe); } else if (action.settingGroup === 'css') { DefaultTemplateRerenderService.handleCssDesignSettingChange( dom, diff --git a/editor/src/app/rerender/mashup/page-layout.service.ts b/editor/src/app/rerender/mashup/page-layout.service.ts index 1be56ad4..42c74c31 100644 --- a/editor/src/app/rerender/mashup/page-layout.service.ts +++ b/editor/src/app/rerender/mashup/page-layout.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { reloadBackendJs, replaceContent } from '../utilities/content'; +import { notifyEntriesRerendered, replaceContent } from '../utilities/content'; import { createEl } from '../utilities/element'; import { PageLayoutService as MainPageLayoutService } from '../common/page-layout.service'; @@ -50,7 +50,7 @@ export class PageLayoutService extends MainPageLayoutService { PageLayoutService.replaceCommonContent(dom, viewData); replaceContent(dom, 'mashupEntries', viewData.mashupEntries); - reloadBackendJs(iframe); + notifyEntriesRerendered(iframe); } private static handleMainColumn( diff --git a/editor/src/app/rerender/template-rerender.service.spec.ts b/editor/src/app/rerender/template-rerender.service.spec.ts new file mode 100644 index 00000000..f46f15b4 --- /dev/null +++ b/editor/src/app/rerender/template-rerender.service.spec.ts @@ -0,0 +1,92 @@ +import { Actions, ActionStatus } from '@ngxs/store'; +import { Subject } from 'rxjs'; + +import { TemplateRerenderService } from './template-rerender.service'; +import { TemplateRenderService } from '../render/template-render.service'; +import { AddSectionEntryFromSyncAction } from '../sites/sections/entries/entries-state/section-entries.actions'; +import { HandleSiteSettingsChildrenChangesAction } from '../sites/settings/site-settings.actions'; +import { SiteSettingChildrenHandler } from './types/components'; + +describe('TemplateRerenderService', () => { + const ENTRY = + '