diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index 59dcf44a07..9101510190 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -28,7 +28,7 @@
- +
diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index b5addb5858..a2697c3eb5 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -252,25 +252,25 @@ describe('AppComponent', () => { const sidenavBackdrop = fixture.debugElement.query(By.css('.mat-drawer-backdrop')).nativeElement; sidenavBackdrop.click(); fixture.detectChanges(); - expect(sidenav.opened).toBe(false); + expect(sidenav.opened).toBe(false); }); it('should close when nav to another guide page', () => { locationService.go('guide/bags'); fixture.detectChanges(); - expect(sidenav.opened).toBe(false); + expect(sidenav.opened).toBe(false); }); it('should close when nav to api page', () => { locationService.go('api'); fixture.detectChanges(); - expect(sidenav.opened).toBe(false); + expect(sidenav.opened).toBe(false); }); it('should close again when nav to market page', () => { locationService.go('features'); fixture.detectChanges(); - expect(sidenav.opened).toBe(false); + expect(sidenav.opened).toBe(false); }); }); @@ -452,16 +452,19 @@ describe('AppComponent', () => { const scrollDelay = 500; let scrollService: ScrollService; let scrollSpy: jasmine.Spy; + let scrollToTopSpy: jasmine.Spy; beforeEach(() => { scrollService = fixture.debugElement.injector.get(ScrollService); scrollSpy = spyOn(scrollService, 'scroll'); + scrollToTopSpy = spyOn(scrollService, 'scrollToTop'); }); it('should not scroll immediately when the docId (path) changes', () => { locationService.go('guide/pipes'); - // deliberately not calling `fixture.detectChanges` because don't want `onDocRendered` + // deliberately not calling `fixture.detectChanges` because don't want `onDocInserted` expect(scrollSpy).not.toHaveBeenCalled(); + expect(scrollToTopSpy).not.toHaveBeenCalled(); }); it('should scroll when just the hash changes (# alone)', () => { @@ -491,7 +494,7 @@ describe('AppComponent', () => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); - it('should scroll when e-nav to the empty path', () => { + it('should scroll when re-nav to the empty path', () => { locationService.go(''); scrollSpy.calls.reset(); @@ -499,17 +502,29 @@ describe('AppComponent', () => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); - it('should scroll after a delay when call onDocRendered directly', fakeAsync(() => { - component.onDocRendered(); + it('should scroll to top when call `onDocRemoved` directly', () => { + scrollToTopSpy.calls.reset(); + + component.onDocRemoved(); + expect(scrollToTopSpy).toHaveBeenCalled(); + }); + + it('should scroll after a delay when call `onDocInserted` directly', fakeAsync(() => { + component.onDocInserted(); expect(scrollSpy).not.toHaveBeenCalled(); + tick(scrollDelay); expect(scrollSpy).toHaveBeenCalled(); })); - it('should scroll (via onDocRendered) when finish navigating to a new doc', fakeAsync(() => { + it('should scroll (via `onDocInserted`) when finish navigating to a new doc', fakeAsync(() => { + expect(scrollToTopSpy).not.toHaveBeenCalled(); + locationService.go('guide/pipes'); - fixture.detectChanges(); // triggers the event that calls onDocRendered + fixture.detectChanges(); // triggers the event that calls `onDocInserted` + expect(scrollToTopSpy).toHaveBeenCalled(); expect(scrollSpy).not.toHaveBeenCalled(); + tick(scrollDelay); expect(scrollSpy).toHaveBeenCalled(); })); @@ -872,7 +887,7 @@ describe('AppComponent', () => { describe('with mocked DocViewer', () => { const getDocViewer = () => fixture.debugElement.query(By.css('aio-doc-viewer')); - const triggerDocRendered = () => getDocViewer().triggerEventHandler('docRendered', {}); + const triggerDocReady = () => getDocViewer().triggerEventHandler('docReady', undefined); beforeEach(() => { createTestingModule('a/b'); @@ -884,7 +899,7 @@ describe('AppComponent', () => { }); describe('initial rendering', () => { - it('should initially add the starting class until the first document is rendered', fakeAsync(() => { + it('should initially add the starting class until the first document is ready', fakeAsync(() => { const getSidenavContainer = () => fixture.debugElement.query(By.css('mat-sidenav-container')); initializeTest(); @@ -892,7 +907,7 @@ describe('AppComponent', () => { expect(component.isStarting).toBe(true); expect(getSidenavContainer().classes['starting']).toBe(true); - triggerDocRendered(); + triggerDocReady(); fixture.detectChanges(); expect(component.isStarting).toBe(true); expect(getSidenavContainer().classes['starting']).toBe(true); @@ -915,7 +930,7 @@ describe('AppComponent', () => { const getProgressBar = () => fixture.debugElement.query(By.directive(MatProgressBar)); const initializeAndCompleteNavigation = () => { initializeTest(); - triggerDocRendered(); + triggerDocReady(); tick(HIDE_DELAY); }; @@ -952,7 +967,7 @@ describe('AppComponent', () => { it('should not be shown when re-navigating to the empty path', fakeAsync(() => { initializeAndCompleteNavigation(); locationService.urlSubject.next(''); - triggerDocRendered(); + triggerDocReady(); locationService.urlSubject.next(''); @@ -963,12 +978,12 @@ describe('AppComponent', () => { tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains. })); - it('should not be shown if the doc is rendered quickly', fakeAsync(() => { + it('should not be shown if the doc is prepared quickly', fakeAsync(() => { initializeAndCompleteNavigation(); locationService.urlSubject.next('c/d'); tick(SHOW_DELAY - 1); - triggerDocRendered(); + triggerDocReady(); tick(1); fixture.detectChanges(); @@ -977,12 +992,12 @@ describe('AppComponent', () => { tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains. })); - it('should be shown if rendering the doc takes too long', fakeAsync(() => { + it('should be shown if preparing the doc takes too long', fakeAsync(() => { initializeAndCompleteNavigation(); locationService.urlSubject.next('c/d'); tick(SHOW_DELAY); - triggerDocRendered(); + triggerDocReady(); fixture.detectChanges(); expect(getProgressBar()).toBeTruthy(); @@ -990,12 +1005,12 @@ describe('AppComponent', () => { tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains. })); - it('should be hidden (after a delay) once the doc is rendered', fakeAsync(() => { + it('should be hidden (after a delay) once the doc has been prepared', fakeAsync(() => { initializeAndCompleteNavigation(); locationService.urlSubject.next('c/d'); tick(SHOW_DELAY); - triggerDocRendered(); + triggerDocReady(); fixture.detectChanges(); expect(getProgressBar()).toBeTruthy(); @@ -1012,10 +1027,10 @@ describe('AppComponent', () => { it('should only take the latest request into account', fakeAsync(() => { initializeAndCompleteNavigation(); locationService.urlSubject.next('c/d'); // The URL changes. - locationService.urlSubject.next('e/f'); // The URL changes again before `onDocRendered()`. + locationService.urlSubject.next('e/f'); // The URL changes again before `onDocReady()`. - tick(SHOW_DELAY - 1); // `onDocRendered()` is triggered (for the last doc), - triggerDocRendered(); // before the progress bar is shown. + tick(SHOW_DELAY - 1); // `onDocReady()` is triggered (for the last doc), + triggerDocReady(); // before the progress bar is shown. tick(1); fixture.detectChanges(); diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index d954fd5183..2d2be1011b 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -203,22 +203,29 @@ export class AppComponent implements OnInit { this.scrollService.scroll(); } - onDocRendered() { + onDocReady() { // Stop fetching timeout (which, when render is fast, means progress bar never shown) clearTimeout(this.isFetchingTimeout); - // Put page in a clean visual state - this.scrollService.scrollToTop(); - - // Scroll 500ms after the doc-viewer has finished rendering the new doc - // The delay is to allow time for async layout to complete + // If progress bar has been shown, keep it for at least 500ms (to avoid flashing). setTimeout(() => { - this.autoScroll(); this.isStarting = false; this.isFetching = false; }, 500); } + onDocRemoved() { + // The previous document has been removed. + // Scroll to top to restore a clean visual state for the new document. + this.scrollService.scrollToTop(); + } + + onDocInserted() { + // Scroll 500ms after the new document has been inserted into the doc-viewer. + // The delay is to allow time for async layout to complete. + setTimeout(() => this.autoScroll(), 500); + } + onDocVersionChange(versionIndex: number) { const version = this.docVersions[versionIndex]; if (version.url) { diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts index 609d3b4334..cf9d50bed9 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts @@ -40,8 +40,7 @@ describe('DocViewerComponent', () => { expect(docViewer).toEqual(jasmine.any(DocViewerComponent)); }); - describe('#doc / #docRendered', () => { - let destroyEmbeddedComponentsSpy: jasmine.Spy; + describe('#doc', () => { let renderSpy: jasmine.Spy; const setCurrentDoc = (contents, id = 'fizz/buzz') => { @@ -49,10 +48,7 @@ describe('DocViewerComponent', () => { parentFixture.detectChanges(); }; - beforeEach(() => { - destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); - renderSpy = spyOn(docViewer, 'render').and.returnValue([null]); - }); + beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.returnValue([null])); it('should render the new document', () => { setCurrentDoc('foo', 'bar'); @@ -64,30 +60,6 @@ describe('DocViewerComponent', () => { expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]); }); - it('should destroy the currently active components (before rendering the new document)', () => { - setCurrentDoc('foo'); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy); - - destroyEmbeddedComponentsSpy.calls.reset(); - renderSpy.calls.reset(); - - setCurrentDoc(null); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy); - }); - - it('should emit `docRendered` after the new document has been rendered', done => { - let completeRender: () => void; - renderSpy.and.returnValue(new Promise(resolve => completeRender = resolve)); - docViewer.docRendered.subscribe(done); - - setCurrentDoc('foo'); - expect(renderSpy).toHaveBeenCalledTimes(1); - - completeRender(); - }); - it('should unsubscribe from the previous "render" observable upon new document', () => { const obs = new ObservableWithSubscriptionSpies(); renderSpy.and.returnValue(obs); @@ -102,22 +74,15 @@ describe('DocViewerComponent', () => { }); it('should ignore falsy document values', () => { - const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); - docViewer.docRendered.subscribe(onDocRenderedSpy); - parentComponent.currentDoc = null; parentFixture.detectChanges(); - expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled(); - expect(onDocRenderedSpy).not.toHaveBeenCalled(); parentComponent.currentDoc = undefined; parentFixture.detectChanges(); - expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled(); - expect(onDocRenderedSpy).not.toHaveBeenCalled(); }); }); @@ -160,166 +125,20 @@ describe('DocViewerComponent', () => { }); it('should stop responding to document changes', () => { - const destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]); - const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); - docViewer.docRendered.subscribe(onDocRenderedSpy); - expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled(); - expect(onDocRenderedSpy).not.toHaveBeenCalled(); docViewer.doc = {contents: 'Some content', id: 'some-id'}; - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1); - expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); - docViewer.ngOnDestroy(); // Also calls `destroyEmbeddedComponents()`. + docViewer.ngOnDestroy(); docViewer.doc = {contents: 'Other content', id: 'other-id'}; - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2); expect(renderSpy).toHaveBeenCalledTimes(1); - expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); docViewer.doc = {contents: 'More content', id: 'more-id'}; - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2); expect(renderSpy).toHaveBeenCalledTimes(1); - expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('#addTitleAndToc()', () => { - const EMPTY_DOC = ''; - const DOC_WITHOUT_H1 = 'Some content'; - const DOC_WITH_H1 = '

Features

Some content'; - const DOC_WITH_NO_TOC_H1 = '

Features

Some content'; - const DOC_WITH_HIDDEN_H1_CONTENT = '

linkFeatures

Some content'; - - const tryDoc = (contents: string, docId = '') => { - docViewer.currViewContainer.innerHTML = contents; - docViewer.addTitleAndToc(docId); - }; - - describe('(title)', () => { - let titleService: MockTitle; - - beforeEach(() => titleService = TestBed.get(Title)); - - it('should set the title if there is an `

` heading', () => { - tryDoc(DOC_WITH_H1); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); - }); - - it('should set the title if there is a `.no-toc` `

` heading', () => { - tryDoc(DOC_WITH_NO_TOC_H1); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); - }); - - it('should set the default title if there is no `

` heading', () => { - tryDoc(DOC_WITHOUT_H1); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); - - tryDoc(EMPTY_DOC); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); - }); - - it('should not include hidden content of the `

` heading in the title', () => { - tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); - }); - - it('should fall back to `textContent` if `innerText` is not available', () => { - const viewContainer = docViewer.currViewContainer; - const querySelector_ = viewContainer.querySelector; - spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => { - const elem = querySelector_.call(viewContainer, selector); - return Object.defineProperties(elem, { - innerText: {value: undefined}, - textContent: {value: 'Text Content'}, - }); - }); - - tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); - - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content'); - }); - - it('should still use `innerText` if available but empty', () => { - const viewContainer = docViewer.currViewContainer; - const querySelector_ = viewContainer.querySelector; - spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => { - const elem = querySelector_.call(viewContainer, selector); - return Object.defineProperties(elem, { - innerText: { value: '' }, - textContent: { value: 'Text Content' } - }); - }); - - tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); - - expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); - }); - }); - - describe('(ToC)', () => { - let tocService: MockTocService; - - const getTocEl = () => docViewerEl.querySelector('aio-toc'); - - beforeEach(() => tocService = TestBed.get(TocService)); - - it('should have an (embedded) ToC if there is an `

` heading', () => { - tryDoc(DOC_WITH_H1, 'foo'); - const tocEl = getTocEl()!; - - expect(tocEl).toBeTruthy(); - expect(tocEl.classList.contains('embedded')).toBe(true); - expect(tocService.genToc).toHaveBeenCalledTimes(1); - expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo'); - }); - - it('should have no ToC if there is a `.no-toc` `

` heading', () => { - tryDoc(DOC_WITH_NO_TOC_H1); - - expect(getTocEl()).toBeFalsy(); - expect(tocService.genToc).not.toHaveBeenCalled(); - }); - - it('should have no ToC if there is no `

` heading', () => { - tryDoc(DOC_WITHOUT_H1); - expect(getTocEl()).toBeFalsy(); - - tryDoc(EMPTY_DOC); - expect(getTocEl()).toBeFalsy(); - - expect(tocService.genToc).not.toHaveBeenCalled(); - }); - - it('should always reset the ToC (before generating the new one)', () => { - expect(tocService.reset).not.toHaveBeenCalled(); - expect(tocService.genToc).not.toHaveBeenCalled(); - - tocService.genToc.calls.reset(); - tryDoc(DOC_WITH_H1, 'foo'); - expect(tocService.reset).toHaveBeenCalledTimes(1); - expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc); - expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo'); - - tocService.genToc.calls.reset(); - tryDoc(DOC_WITH_NO_TOC_H1, 'bar'); - expect(tocService.reset).toHaveBeenCalledTimes(2); - expect(tocService.genToc).not.toHaveBeenCalled(); - - tocService.genToc.calls.reset(); - tryDoc(DOC_WITHOUT_H1, 'baz'); - expect(tocService.reset).toHaveBeenCalledTimes(3); - expect(tocService.genToc).not.toHaveBeenCalled(); - - tocService.genToc.calls.reset(); - tryDoc(EMPTY_DOC, 'qux'); - expect(tocService.reset).toHaveBeenCalledTimes(4); - expect(tocService.genToc).not.toHaveBeenCalled(); - }); }); }); @@ -350,9 +169,174 @@ describe('DocViewerComponent', () => { }); }); + describe('#prepareTitleAndToc()', () => { + const EMPTY_DOC = ''; + const DOC_WITHOUT_H1 = 'Some content'; + const DOC_WITH_H1 = '

Features

Some content'; + const DOC_WITH_NO_TOC_H1 = '

Features

Some content'; + const DOC_WITH_HIDDEN_H1_CONTENT = '

linkFeatures

Some content'; + let titleService: MockTitle; + let tocService: MockTocService; + let targetEl: HTMLElement; + + const getTocEl = () => targetEl.querySelector('aio-toc'); + const doPrepareTitleAndToc = (contents: string, docId = '') => { + targetEl.innerHTML = contents; + return docViewer.prepareTitleAndToc(targetEl, docId); + }; + const doAddTitleAndToc = (contents: string, docId = '') => { + const addTitleAndToc = doPrepareTitleAndToc(contents, docId); + return addTitleAndToc(); + }; + + beforeEach(() => { + titleService = TestBed.get(Title); + tocService = TestBed.get(TocService); + + targetEl = document.createElement('div'); + document.body.appendChild(targetEl); // Required for `innerText` to work as expected. + }); + + afterEach(() => document.body.removeChild(targetEl)); + + it('should return a function for doing the actual work', () => { + const addTitleAndToc = doPrepareTitleAndToc(DOC_WITH_H1); + + expect(getTocEl()).toBeTruthy(); + expect(titleService.setTitle).not.toHaveBeenCalled(); + expect(tocService.reset).not.toHaveBeenCalled(); + expect(tocService.genToc).not.toHaveBeenCalled(); + + addTitleAndToc(); + + expect(titleService.setTitle).toHaveBeenCalledTimes(1); + expect(tocService.reset).toHaveBeenCalledTimes(1); + expect(tocService.genToc).toHaveBeenCalledTimes(1); + }); + + describe('(title)', () => { + it('should set the title if there is an `

` heading', () => { + doAddTitleAndToc(DOC_WITH_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + }); + + it('should set the title if there is a `.no-toc` `

` heading', () => { + doAddTitleAndToc(DOC_WITH_NO_TOC_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + }); + + it('should set the default title if there is no `

` heading', () => { + doAddTitleAndToc(DOC_WITHOUT_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + + doAddTitleAndToc(EMPTY_DOC); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + }); + + it('should not include hidden content of the `

` heading in the title', () => { + doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + }); + + it('should fall back to `textContent` if `innerText` is not available', () => { + const querySelector_ = targetEl.querySelector; + spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { + const elem = querySelector_.call(targetEl, selector); + return Object.defineProperties(elem, { + innerText: {value: undefined}, + textContent: {value: 'Text Content'}, + }); + }); + + doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); + + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content'); + }); + + it('should still use `innerText` if available but empty', () => { + const querySelector_ = targetEl.querySelector; + spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { + const elem = querySelector_.call(targetEl, selector); + return Object.defineProperties(elem, { + innerText: { value: '' }, + textContent: { value: 'Text Content' } + }); + }); + + doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); + + expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + }); + }); + + describe('(ToC)', () => { + it('should add an embedded ToC element if there is an `

` heading', () => { + doPrepareTitleAndToc(DOC_WITH_H1); + const tocEl = getTocEl()!; + + expect(tocEl).toBeTruthy(); + expect(tocEl.classList.contains('embedded')).toBe(true); + }); + + it('should not add a ToC element if there is a `.no-toc` `

` heading', () => { + doPrepareTitleAndToc(DOC_WITH_NO_TOC_H1); + expect(getTocEl()).toBeFalsy(); + }); + + it('should not add a ToC element if there is no `

` heading', () => { + doPrepareTitleAndToc(DOC_WITHOUT_H1); + expect(getTocEl()).toBeFalsy(); + + doPrepareTitleAndToc(EMPTY_DOC); + expect(getTocEl()).toBeFalsy(); + }); + + it('should generate ToC entries if there is an `

` heading', () => { + doAddTitleAndToc(DOC_WITH_H1, 'foo'); + + expect(tocService.genToc).toHaveBeenCalledTimes(1); + expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); + }); + + it('should not generate ToC entries if there is a `.no-toc` `

` heading', () => { + doAddTitleAndToc(DOC_WITH_NO_TOC_H1); + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + + it('should not generate ToC entries if there is no `

` heading', () => { + doAddTitleAndToc(DOC_WITHOUT_H1); + doAddTitleAndToc(EMPTY_DOC); + + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + + it('should always reset the ToC (before generating the new one)', () => { + doAddTitleAndToc(DOC_WITH_H1, 'foo'); + expect(tocService.reset).toHaveBeenCalledTimes(1); + expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc); + expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); + + tocService.genToc.calls.reset(); + + doAddTitleAndToc(DOC_WITH_NO_TOC_H1, 'bar'); + expect(tocService.reset).toHaveBeenCalledTimes(2); + expect(tocService.genToc).not.toHaveBeenCalled(); + + doAddTitleAndToc(DOC_WITHOUT_H1, 'baz'); + expect(tocService.reset).toHaveBeenCalledTimes(3); + expect(tocService.genToc).not.toHaveBeenCalled(); + + doAddTitleAndToc(EMPTY_DOC, 'qux'); + expect(tocService.reset).toHaveBeenCalledTimes(4); + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + }); + }); + describe('#render()', () => { - let addTitleAndTocSpy: jasmine.Spy; + let destroyEmbeddedComponentsSpy: jasmine.Spy; let embedIntoSpy: jasmine.Spy; + let prepareTitleAndTocSpy: jasmine.Spy; let swapViewsSpy: jasmine.Spy; const doRender = (contents: string | null, id = 'foo') => @@ -362,8 +346,9 @@ describe('DocViewerComponent', () => { beforeEach(() => { const embedComponentsService = TestBed.get(EmbedComponentsService) as MockEmbedComponentsService; - addTitleAndTocSpy = spyOn(docViewer, 'addTitleAndToc'); + destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([])); + prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc'); swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined)); }); @@ -396,26 +381,37 @@ describe('DocViewerComponent', () => { expect(docViewerEl.textContent).toBe(''); }); + it('should prepare the title and ToC (before embedding components)', async () => { + prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => { + expect(targetEl.innerHTML).toBe('Some content'); + expect(docId).toBe('foo'); + }); + + await doRender('Some content', 'foo'); + + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); + expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(embedIntoSpy); + }); + it('should set the title and ToC (after the content has been set)', async () => { + const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); + prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); + addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content')); - await doRender('Foo content', 'foo'); + await doRender('Foo content'); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(addTitleAndTocSpy).toHaveBeenCalledWith('foo'); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content')); - await doRender('Bar content', 'bar'); + await doRender('Bar content'); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2); - expect(addTitleAndTocSpy).toHaveBeenCalledWith('bar'); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('')); - await doRender('', 'baz'); + await doRender(''); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3); - expect(addTitleAndTocSpy).toHaveBeenCalledWith('baz'); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content')); - await doRender('Qux content', 'qux'); + await doRender('Qux content'); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4); - expect(addTitleAndTocSpy).toHaveBeenCalledWith('qux'); }); }); @@ -461,12 +457,29 @@ describe('DocViewerComponent', () => { }); }); + describe('(destroying old embedded components)', () => { + it('should destroy old embedded components after creating new embedded components', async () => { + await doRender('
'); + + expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); + expect(embedIntoSpy).toHaveBeenCalledBefore(destroyEmbeddedComponentsSpy); + }); + + it('should still destroy old embedded components if the new document is empty', async () => { + await doRender(''); + expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); + + await doRender(null); + expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2); + }); + }); + describe('(swapping views)', () => { - it('should swap the views after creating embedded components', async () => { + it('should swap the views after destroying old embedded components', async () => { await doRender('
'); expect(swapViewsSpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).toHaveBeenCalledBefore(swapViewsSpy); + expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(swapViewsSpy); }); it('should still swap the views if the document is empty', async () => { @@ -477,6 +490,15 @@ describe('DocViewerComponent', () => { expect(swapViewsSpy).toHaveBeenCalledTimes(2); }); + it('should pass the `addTitleAndToc` callback', async () => { + const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); + prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); + + await doRender('
'); + + expect(swapViewsSpy).toHaveBeenCalledWith(addTitleAndTocSpy); + }); + it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => { const obs = new ObservableWithSubscriptionSpies(); swapViewsSpy.and.returnValue(obs); @@ -499,6 +521,25 @@ describe('DocViewerComponent', () => { beforeEach(() => logger = TestBed.get(Logger)); + it('when `prepareTitleAndTocSpy()` fails', async () => { + const error = Error('Typical `prepareTitleAndToc()` error'); + prepareTitleAndTocSpy.and.callFake(() => { + expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); + throw error; + }); + + await doRender('Some content', 'foo'); + + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); + expect(embedIntoSpy).not.toHaveBeenCalled(); + expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); + expect(swapViewsSpy).not.toHaveBeenCalled(); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + expect(logger.output.error).toEqual([ + ['[DocViewer]: Error preparing document \'foo\'.', error], + ]); + }); + it('when `EmbedComponentsService.embedInto()` fails', async () => { const error = Error('Typical `embedInto()` error'); embedIntoSpy.and.callFake(() => { @@ -506,14 +547,34 @@ describe('DocViewerComponent', () => { throw error; }); - await doRender('Some content', 'foo'); + await doRender('Some content', 'bar'); + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(embedIntoSpy).toHaveBeenCalledTimes(1); + expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); expect(swapViewsSpy).not.toHaveBeenCalled(); - expect(addTitleAndTocSpy).not.toHaveBeenCalled(); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ - ['[DocViewer]: Error preparing document \'foo\'.', error], + ['[DocViewer]: Error preparing document \'bar\'.', error], + ]); + }); + + it('when `destroyEmbeddedComponents()` fails', async () => { + const error = Error('Typical `destroyEmbeddedComponents()` error'); + destroyEmbeddedComponentsSpy.and.callFake(() => { + expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); + throw error; + }); + + await doRender('Some content', 'baz'); + + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); + expect(embedIntoSpy).toHaveBeenCalledTimes(1); + expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); + expect(swapViewsSpy).not.toHaveBeenCalled(); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + expect(logger.output.error).toEqual([ + ['[DocViewer]: Error preparing document \'baz\'.', error], ]); }); @@ -524,33 +585,49 @@ describe('DocViewerComponent', () => { throw error; }); - await doRender('Some content', 'bar'); + await doRender('Some content', 'qux'); + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(embedIntoSpy).toHaveBeenCalledTimes(1); + expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).toHaveBeenCalledTimes(1); - expect(addTitleAndTocSpy).not.toHaveBeenCalled(); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ - ['[DocViewer]: Error preparing document \'bar\'.', error], + ['[DocViewer]: Error preparing document \'qux\'.', error], ]); }); + }); - it('when `addTitleAndTocSpy()` fails', async () => { - const error = Error('Typical `addTitleAndToc()` error'); - addTitleAndTocSpy.and.callFake(() => { - expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); - throw error; - }); + describe('(events)', () => { + it('should emit `docReady` after embedding components', async () => { + const onDocReadySpy = jasmine.createSpy('onDocReady'); + docViewer.docReady.subscribe(onDocReadySpy); - await doRender('Some content', 'baz'); + await doRender('Some content'); - expect(embedIntoSpy).toHaveBeenCalledTimes(1); - expect(swapViewsSpy).toHaveBeenCalledTimes(1); - expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - expect(logger.output.error).toEqual([ - ['[DocViewer]: Error preparing document \'baz\'.', error], - ]); + expect(onDocReadySpy).toHaveBeenCalledTimes(1); + expect(embedIntoSpy).toHaveBeenCalledBefore(onDocReadySpy); + }); + + it('should emit `docReady` before destroying old embedded components and swapping views', async () => { + const onDocReadySpy = jasmine.createSpy('onDocReady'); + docViewer.docReady.subscribe(onDocReadySpy); + + await doRender('Some content'); + + expect(onDocReadySpy).toHaveBeenCalledTimes(1); + expect(onDocReadySpy).toHaveBeenCalledBefore(destroyEmbeddedComponentsSpy); + expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy); + }); + + it('should emit `docRendered` after swapping views', async () => { + const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); + docViewer.docRendered.subscribe(onDocRenderedSpy); + + await doRender('Some content'); + + expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); + expect(swapViewsSpy).toHaveBeenCalledBefore(onDocRenderedSpy); }); }); }); @@ -559,8 +636,9 @@ describe('DocViewerComponent', () => { let oldCurrViewContainer: HTMLElement; let oldNextViewContainer: HTMLElement; - const doSwapViews = () => new Promise((resolve, reject) => - docViewer.swapViews().subscribe(resolve, reject)); + const doSwapViews = (cb?: () => void) => + new Promise((resolve, reject) => + docViewer.swapViews(cb).subscribe(resolve, reject)); beforeEach(() => { oldCurrViewContainer = docViewer.currViewContainer; @@ -598,6 +676,73 @@ describe('DocViewerComponent', () => { expect(docViewer.nextViewContainer).toBe(oldNextViewContainer); }); + it('should emit `docRemoved` after removing the leaving view', async () => { + const onDocRemovedSpy = jasmine.createSpy('onDocRemoved').and.callFake(() => { + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + }); + + docViewer.docRemoved.subscribe(onDocRemovedSpy); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + + await doSwapViews(); + + expect(onDocRemovedSpy).toHaveBeenCalledTimes(1); + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + + it('should not emit `docRemoved` if the leaving view is already removed', async () => { + const onDocRemovedSpy = jasmine.createSpy('onDocRemoved'); + + docViewer.docRemoved.subscribe(onDocRemovedSpy); + docViewerEl.removeChild(oldCurrViewContainer); + + await doSwapViews(); + + expect(onDocRemovedSpy).not.toHaveBeenCalled(); + }); + + it('should emit `docInserted` after inserting the entering view', async () => { + const onDocInsertedSpy = jasmine.createSpy('onDocInserted').and.callFake(() => { + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + + docViewer.docInserted.subscribe(onDocInsertedSpy); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + + await doSwapViews(); + + expect(onDocInsertedSpy).toHaveBeenCalledTimes(1); + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + + it('should call the callback after inserting the entering view', async () => { + const onInsertedCb = jasmine.createSpy('onInsertedCb').and.callFake(() => { + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + const onDocInsertedSpy = jasmine.createSpy('onDocInserted'); + + docViewer.docInserted.subscribe(onDocInsertedSpy); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + + await doSwapViews(onInsertedCb); + + expect(onInsertedCb).toHaveBeenCalledTimes(1); + expect(onInsertedCb).toHaveBeenCalledBefore(onDocInsertedSpy); + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + it('should empty the previous view', async () => { await doSwapViews(); diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts index 40de01b11e..0580f27778 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts @@ -48,8 +48,21 @@ export class DocViewerComponent implements DoCheck, OnDestroy { } } - @Output() - docRendered = new EventEmitter(); + // The new document is ready to be inserted into the viewer. + // (Embedded components have been loaded and instantiated, if necessary.) + @Output() docReady = new EventEmitter(); + + // The previous document has been removed from the viewer. + // (The leaving animation (if any) has been completed and the node has been removed from the DOM.) + @Output() docRemoved = new EventEmitter(); + + // The new document has been inserted into the viewer. + // (The node has been inserted into the DOM, but the entering animation may still be in progress.) + @Output() docInserted = new EventEmitter(); + + // The new document has been fully rendered into the viewer. + // (The entering animation has been completed.) + @Output() docRendered = new EventEmitter(); constructor( elementRef: ElementRef, @@ -70,9 +83,7 @@ export class DocViewerComponent implements DoCheck, OnDestroy { this.onDestroy$.subscribe(() => this.destroyEmbeddedComponents()); this.docContents$ - .do(() => this.destroyEmbeddedComponents()) .switchMap(newDoc => this.render(newDoc)) - .do(() => this.docRendered.emit()) .takeUntil(this.onDestroy$) .subscribe(); } @@ -85,27 +96,6 @@ export class DocViewerComponent implements DoCheck, OnDestroy { this.onDestroy$.emit(); } - /** - * Set up the window title and ToC. - */ - protected addTitleAndToc(docId: string): void { - this.tocService.reset(); - const titleEl = this.currViewContainer.querySelector('h1'); - let title = ''; - - // Only create TOC for docs with an

title - // If you don't want a TOC, add "no-toc" class to

- if (titleEl) { - title = (typeof titleEl.innerText === 'string') ? titleEl.innerText : titleEl.textContent; - if (!/(no-toc|notoc)/i.test(titleEl.className)) { - this.tocService.genToc(this.currViewContainer, docId); - titleEl.insertAdjacentHTML('afterend', ''); - } - } - - this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular'); - } - /** * Destroy the embedded components to avoid memory leaks. */ @@ -114,20 +104,53 @@ export class DocViewerComponent implements DoCheck, OnDestroy { this.embeddedComponentRefs = []; } + /** + * Prepare for setting the window title and ToC. + * Return a function to actually set them. + */ + protected prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void { + const titleEl = targetElem.querySelector('h1'); + const hasToc = !!titleEl && !/no-?toc/i.test(titleEl.className); + + if (hasToc) { + titleEl.insertAdjacentHTML('afterend', ''); + } + + return () => { + this.tocService.reset(); + let title = ''; + + // Only create ToC for docs with an `

` heading. + // If you don't want a ToC, add "no-toc" class to `

`. + if (titleEl) { + title = (typeof titleEl.innerText === 'string') ? titleEl.innerText : titleEl.textContent; + + if (hasToc) { + this.tocService.genToc(targetElem, docId); + } + } + + this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular'); + }; + } + /** * Add doc content to host element and build it out with embedded components. */ protected render(doc: DocumentContents): Observable { + let addTitleAndToc: () => void; + return this.void$ - .do(() => { - // Security: `doc.contents` is always authored by the documentation team - // and is considered to be safe. - this.nextViewContainer.innerHTML = doc.contents || ''; - }) + // Security: `doc.contents` is always authored by the documentation team + // and is considered to be safe. + .do(() => this.nextViewContainer.innerHTML = doc.contents || '') + .do(() => addTitleAndToc = this.prepareTitleAndToc(this.nextViewContainer, doc.id)) .switchMap(() => this.embedComponentsService.embedInto(this.nextViewContainer)) + .do(() => this.docReady.emit()) + .do(() => this.destroyEmbeddedComponents()) .do(componentRefs => this.embeddedComponentRefs = componentRefs) - .switchMap(() => this.swapViews()) - .do(() => this.addTitleAndToc(doc.id)) + .switchMap(() => this.swapViews(addTitleAndToc)) + .do(() => this.docRendered.emit()) .catch(err => { this.nextViewContainer.innerHTML = ''; this.logger.error(`[DocViewer]: Error preparing document '${doc.id}'.`, err); @@ -139,8 +162,12 @@ export class DocViewerComponent implements DoCheck, OnDestroy { * Swap the views, removing `currViewContainer` and inserting `nextViewContainer`. * (At this point all content should be ready, including having loaded and instantiated embedded * components.) + * + * Optionally, run a callback as soon as `nextViewContainer` has been inserted, but before the + * entering animation has been completed. This is useful for work that needs to be done as soon as + * the element has been attached to the DOM. */ - protected swapViews(): Observable { + protected swapViews(onInsertedCb = () => undefined): Observable { const raf$ = new Observable(subscriber => { const rafId = requestAnimationFrame(() => { subscriber.next(); @@ -174,12 +201,15 @@ export class DocViewerComponent implements DoCheck, OnDestroy { done$ = done$ // Remove the current view from the viewer. .switchMap(() => animateLeave(this.currViewContainer)) - .do(() => this.currViewContainer.parentElement.removeChild(this.currViewContainer)); + .do(() => this.currViewContainer.parentElement.removeChild(this.currViewContainer)) + .do(() => this.docRemoved.emit()); } return done$ // Insert the next view into the viewer. .do(() => this.hostElement.appendChild(this.nextViewContainer)) + .do(() => onInsertedCb()) + .do(() => this.docInserted.emit()) .switchMap(() => animateEnter(this.nextViewContainer)) // Update the view references and clean up unused nodes. .do(() => { diff --git a/aio/src/testing/doc-viewer-utils.ts b/aio/src/testing/doc-viewer-utils.ts index 1240b489ac..75bd3e7d86 100644 --- a/aio/src/testing/doc-viewer-utils.ts +++ b/aio/src/testing/doc-viewer-utils.ts @@ -21,10 +21,10 @@ export class TestDocViewerComponent extends DocViewerComponent { currViewContainer: HTMLElement; nextViewContainer: HTMLElement; - addTitleAndToc(docId: string): void { return null as any; } destroyEmbeddedComponents(): void { return null as any; } + prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void { return null as any; } render(doc: DocumentContents): Observable { return null as any; } - swapViews(): Observable { return null as any; } + swapViews(onInsertedCb?: () => void): Observable { return null as any; } }