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 = + '
  • entry
  • '; + + let doc: Document; + let iframe: HTMLIFrameElement; + let dispatchEvent: jasmine.Spy; + let actions$: Subject<{ action: unknown; status: ActionStatus }>; + let viewData: Record; + let service: TemplateRerenderService; + + function succeed(action: unknown) { + actions$.next({ action, status: ActionStatus.Successful }); + } + + function dispatchedEvents(): string[] { + return dispatchEvent.calls.allArgs().map(([event]) => event.type); + } + + beforeEach(() => { + doc = document.implementation.createHTMLDocument(''); + dispatchEvent = jasmine.createSpy('dispatchEvent'); + iframe = { + contentDocument: doc, + contentWindow: { dispatchEvent }, + } as unknown as HTMLIFrameElement; + actions$ = new Subject(); + viewData = {}; + service = new TemplateRerenderService( + { getViewData: () => viewData } as unknown as TemplateRenderService, + actions$ as unknown as Actions, + ); + }); + + describe('entry creation', () => { + const created = new AddSectionEntryFromSyncAction('', 'works', { + tag: null, + before_entry: null, + }); + + beforeEach(() => { + viewData.entries = ENTRY + ENTRY; + viewData.portfolioThumbnails = ''; + service.handleEntryCreationRerender(iframe); + }); + + it('rerenders a page that has no "create new entry" link yet', () => { + doc.body.innerHTML = '
      '; + + succeed(created); + + expect(doc.querySelectorAll('#pageEntries .xEntry').length).toBe(2); + expect(dispatchedEvents()).toEqual(['entriesRerendered']); + }); + + it('keeps the "create new entry" link of every entry', () => { + doc.body.innerHTML = '
        '; + + succeed(created); + + expect(doc.querySelectorAll('.xEntry .xCreateNewEntry').length).toBe(2); + }); + + it('rerenders a page without an entries list', () => { + succeed(created); + + expect(dispatchedEvents()).toEqual(['entriesRerendered']); + }); + }); + + it('rerenders site settings on a page without entries', () => { + doc.body.innerHTML = '
        '; + viewData.siteBanners = ''; + service.handleSiteSettingChildrenHandleRerender(iframe, { + banners: { id: 'siteBanners', dataKey: 'siteBanners' }, + } as SiteSettingChildrenHandler); + + succeed(new HandleSiteSettingsChildrenChangesAction('banners')); + + expect(doc.querySelector('#siteBanners .banner')).not.toBeNull(); + expect(dispatchedEvents()).toEqual(['entriesRerendered']); + }); +}); diff --git a/editor/src/app/rerender/template-rerender.service.ts b/editor/src/app/rerender/template-rerender.service.ts index 0dccb7e8..fb4e17b2 100644 --- a/editor/src/app/rerender/template-rerender.service.ts +++ b/editor/src/app/rerender/template-rerender.service.ts @@ -33,10 +33,7 @@ import { UpdateSiteSettingsAction, } from '../sites/settings/site-settings.actions'; import { Component, SiteSettingChildrenHandler } from './types/components'; -import { - removeExtraAddBtnAndAddListeners, - replaceContent, -} from './utilities/content'; +import { notifyEntriesRerendered, replaceContent } from './utilities/content'; import { HandleSiteTemplateSettingsAction, ResetToDefaultsSiteTemplateSettingsAction, @@ -260,7 +257,7 @@ export class TemplateRerenderService { viewData.portfolioThumbnails, ); - removeExtraAddBtnAndAddListeners(iframe); + notifyEntriesRerendered(iframe); }); } @@ -272,7 +269,7 @@ export class TemplateRerenderService { replaceContent(iframe.contentDocument, 'pageEntries', viewData.entries); - removeExtraAddBtnAndAddListeners(iframe); + notifyEntriesRerendered(iframe); }); } @@ -319,7 +316,7 @@ export class TemplateRerenderService { viewData.portfolioThumbnails, ); - removeExtraAddBtnAndAddListeners(iframe); + notifyEntriesRerendered(iframe); }); } @@ -350,7 +347,7 @@ export class TemplateRerenderService { replaceContent(iframe.contentDocument, comp.id, viewData[comp.dataKey]), ); - removeExtraAddBtnAndAddListeners(iframe); + notifyEntriesRerendered(iframe); } public unsubscribe(win: Window, subList: Subscription[]): void { diff --git a/editor/src/app/rerender/utilities/content.ts b/editor/src/app/rerender/utilities/content.ts index fb0e6a78..31443ce4 100644 --- a/editor/src/app/rerender/utilities/content.ts +++ b/editor/src/app/rerender/utilities/content.ts @@ -11,18 +11,9 @@ export function replaceContent( element.appendChild(dom.createRange().createContextualFragment(sectionHtml)); } -export function removeExtraAddBtnAndAddListeners( - iframe: HTMLIFrameElement, -): void { - // remove extra 'create entry' button due to backend js reload - const createEntriesList = - iframe.contentDocument.getElementsByClassName('xCreateNewEntry'); - createEntriesList[createEntriesList.length - 1].remove(); - - reloadBackendJs(iframe); -} - -export function reloadBackendJs(iframe: HTMLIFrameElement) { - // reload backend js - iframe.contentWindow.dispatchEvent(new Event('addEntry')); +export function notifyEntriesRerendered(iframe: HTMLIFrameElement) { + // The preview scripts re-init the replaced entries: BertaEditor re-binds the + // entry listeners and replaces the "create new entry" link after the list, + // BertaPortfolio re-binds the thumbnail links + iframe.contentWindow.dispatchEvent(new Event('entriesRerendered')); } diff --git a/engine/js/BertaEditor.js b/engine/js/BertaEditor.js index 9bd582b5..5d52ca88 100644 --- a/engine/js/BertaEditor.js +++ b/engine/js/BertaEditor.js @@ -13,7 +13,7 @@ var BertaEditor = class { // Bound once: onDOMReadyDo runs again after every re-render and adds these // listeners again, adding the same function twice is a no-op this.onDOMReadyDo = this.onDOMReadyDo.bind(this); - this.onAddEntry = this.onAddEntry.bind(this); + this.onEntriesRerendered = this.onEntriesRerendered.bind(this); this.onBgEditClick = this.onBgEditClick.bind(this); this.onGalleryEditClick = this.onGalleryEditClick.bind(this); this.entryCreate = this.entryCreate.bind(this); @@ -24,13 +24,13 @@ var BertaEditor = class { window.addEventListener(e, this.onDOMReadyDo); }); - window.addEventListener("addEntry", this.onAddEntry); + window.addEventListener("entriesRerendered", this.onEntriesRerendered); window.BertaHelpers.onDomReady(this.onDOMReady.bind(this)); } - onAddEntry() { - // after adding entry sync state + onEntriesRerendered() { + // the entries were replaced after a state change, sync the state window.redux_store.dispatch(Actions.getState(window.getCurrentSite())); this.onDOMReadyDo(); } diff --git a/engine/js/BertaPortfolio.js b/engine/js/BertaPortfolio.js index b1435f76..b24643e7 100644 --- a/engine/js/BertaPortfolio.js +++ b/engine/js/BertaPortfolio.js @@ -5,11 +5,14 @@ var BertaPortfolio = class { } else { this.onDOMReady(); } - window.addEventListener('addEntry', this.onAddPortfolio.bind(this)); + window.addEventListener( + 'entriesRerendered', + this.onEntriesRerendered.bind(this), + ); } - onAddPortfolio() { - // after adding portfolio entry + onEntriesRerendered() { + // the thumbnails may have been replaced too this.portfolioThumbnails(); } @@ -38,7 +41,7 @@ var BertaPortfolio = class { portfolioThumbnails() { document.querySelectorAll('.portfolioThumbnails a').forEach((link) => { - // runs again after every addEntry, links that weren't re-rendered already have a listener + // runs again after every entries rerender, links that weren't re-rendered already have a listener if (link.dataset.portfolioListener) { return; }