From 5f4dc62e4e322ea66fee0f3c2f70e15f40bee0fc Mon Sep 17 00:00:00 2001 From: Jean-Francois Cere Date: Wed, 21 Feb 2018 08:54:28 -0500 Subject: [PATCH] Fix mz-collapsible-item *ngFor manipulation (#289) --- .../collapsible/collapsible.component.html | 30 +++++ .../app/collapsible/collapsible.component.ts | 127 +++++++++++++++--- .../src/app/collapsible/collapsible.module.ts | 3 +- .../collapsible/collapsible.component.html | 3 +- src/app/collapsible/collapsible.component.ts | 11 +- .../collapsible.component.unit.spec.ts | 21 +-- .../collapsible.component.view.spec.ts | 45 +++++-- .../remove-component-host.ts | 23 ++-- .../remove-component-host.view.spec.ts | 58 ++++++-- ...sidenav-collapsible.component.view.spec.ts | 9 +- 10 files changed, 245 insertions(+), 85 deletions(-) diff --git a/demo-app/src/app/collapsible/collapsible.component.html b/demo-app/src/app/collapsible/collapsible.component.html index 81a05a5b..90f5548d 100644 --- a/demo-app/src/app/collapsible/collapsible.component.html +++ b/demo-app/src/app/collapsible/collapsible.component.html @@ -2,6 +2,7 @@

Collapsible

import { MzCollapsibleModule } from 'ng2-materialize'
+
Accordion
@@ -143,6 +144,35 @@
Preselected Section

+
+ +
Playground
+ +

+ The mz-collapsible-item component can be dynamically manipulated when used with *ngFor allowing you to add, remove or replace items in the list. +

+ +
+ + + + + {{ item.header }} +

{{ item.body }}

+
+ +
+ +
+ + + + + +
+
+
+
HTML Structure
diff --git a/demo-app/src/app/collapsible/collapsible.component.ts b/demo-app/src/app/collapsible/collapsible.component.ts index 9cf7e9cc..c3a1b539 100644 --- a/demo-app/src/app/collapsible/collapsible.component.ts +++ b/demo-app/src/app/collapsible/collapsible.component.ts @@ -11,7 +11,71 @@ import { IPropertyRow } from '../shared/properties-table/properties-table.compon animations: [ROUTE_ANIMATION], }) export class CollapsibleComponent { - collapsibleProperties: IPropertyRow[] = [ + + // fake data + simpleCollapsibleItems = [ + { + icon: 'cloud', + header: 'First', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur + adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum + dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + + }, + { + icon: 'flash', + header: 'Second', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + }, + { + icon: 'gamepad', + header: 'Third', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + }, + ]; + + // playground + playgroundCollapsibleItems = [ + { + icon: 'cloud', + header: 'First', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur + adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum + dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + + }, + { + icon: 'flash', + header: 'Second', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + }, + { + icon: 'gamepad', + header: 'Third', + body: ` + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`, + }, + ]; + + // table properties + collapsibleProperties: IPropertyRow[] = [ { name: 'mode', mandatory: false, type: 'string', @@ -46,10 +110,10 @@ export class CollapsibleComponent { }, ]; - public simpleCollapsibleItems = [ - { - icon: 'cloud', - header: 'First', + add() { + this.playgroundCollapsibleItems.push({ + icon: 'plus', + header: 'Added', body: ` Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation @@ -58,21 +122,42 @@ export class CollapsibleComponent { veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + }); + } - }, - { - icon: 'flash', - header: 'Second', - body: ` - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, - }, - { - icon: 'gamepad', - header: 'Third', - body: ` - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, - }, - ]; + remove() { + this.playgroundCollapsibleItems.splice(this.playgroundCollapsibleItems.length - 1); + } + + replace() { + this.playgroundCollapsibleItems = [ + { + icon: 'account', + header: 'Fourth', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur + adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum + dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + + }, + { + icon: 'star', + header: 'Fifth', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + }, + { + icon: 'heart', + header: 'Sixth', + body: ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`, + }, + ]; + } } diff --git a/demo-app/src/app/collapsible/collapsible.module.ts b/demo-app/src/app/collapsible/collapsible.module.ts index 397c253b..9d309350 100644 --- a/demo-app/src/app/collapsible/collapsible.module.ts +++ b/demo-app/src/app/collapsible/collapsible.module.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { MzCollapsibleModule, MzIconMdiModule } from 'ng2-materialize'; +import { MzButtonModule, MzCollapsibleModule, MzIconMdiModule } from 'ng2-materialize'; import { CodeSnippetModule } from '../shared/code-snippet/code-snippet.module'; import { PropertiesTableModule } from '../shared/properties-table/properties-table.module'; @@ -12,6 +12,7 @@ import { ROUTES } from './collapsible.routing'; imports: [ CodeSnippetModule, CommonModule, + MzButtonModule, MzCollapsibleModule, MzIconMdiModule, PropertiesTableModule, diff --git a/src/app/collapsible/collapsible.component.html b/src/app/collapsible/collapsible.component.html index 34804a91..71ae786c 100644 --- a/src/app/collapsible/collapsible.component.html +++ b/src/app/collapsible/collapsible.component.html @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/src/app/collapsible/collapsible.component.ts b/src/app/collapsible/collapsible.component.ts index 4a462a15..916fa2a2 100644 --- a/src/app/collapsible/collapsible.component.ts +++ b/src/app/collapsible/collapsible.component.ts @@ -1,6 +1,5 @@ import { AfterViewInit, - ChangeDetectorRef, Component, ContentChildren, ElementRef, @@ -25,7 +24,9 @@ export class MzCollapsibleComponent implements AfterViewInit { @ViewChild('collapsible') collapsible: ElementRef; @ContentChildren(MzCollapsibleItemComponent) items: QueryList; - constructor(public changeDetectorRef: ChangeDetectorRef, public renderer: Renderer) { } + constructor( + public renderer: Renderer, + ) { } ngAfterViewInit(): void { this.handleDataCollapsible(); @@ -39,11 +40,7 @@ export class MzCollapsibleComponent implements AfterViewInit { onOpen: this.onOpen, }; - // need setTimeout otherwise loading directly on the page cause an error - setTimeout(() => this.renderer.invokeElementMethod($(this.collapsible.nativeElement), 'collapsible', [options])); - - // forcing changes detection for unit test - this.changeDetectorRef.detectChanges(); + this.renderer.invokeElementMethod($(this.collapsible.nativeElement), 'collapsible', [options]); } handleDataCollapsible() { diff --git a/src/app/collapsible/collapsible.component.unit.spec.ts b/src/app/collapsible/collapsible.component.unit.spec.ts index 943a8476..ec6cf294 100644 --- a/src/app/collapsible/collapsible.component.unit.spec.ts +++ b/src/app/collapsible/collapsible.component.unit.spec.ts @@ -1,4 +1,4 @@ -import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MzCollapsibleComponent } from './collapsible.component'; @@ -71,11 +71,7 @@ describe('MzCollapsibleComponent:unit', () => { describe('initCollapsible', () => { - function forceSetTimeoutEnd() { - tick(1); // force setTimeout execution - } - - it('should initialize collapsible using jquery', fakeAsync(() => { + it('should initialize collapsible using jquery', async(() => { component.onClose = () => {}; component.onOpen = () => {}; @@ -92,8 +88,6 @@ describe('MzCollapsibleComponent:unit', () => { component.initCollapsible(); - forceSetTimeoutEnd(); - expect(component.renderer.invokeElementMethod) .toHaveBeenCalledWith( mockJQueryCollapsibleNativeElement, @@ -104,16 +98,5 @@ describe('MzCollapsibleComponent:unit', () => { }], ); })); - - it('should call detectChanges', () => { - - component.mode = 'accordion'; - - spyOn(component.changeDetectorRef, 'detectChanges').and.callThrough(); - - component.initCollapsible(); - - expect(component.changeDetectorRef.detectChanges).toHaveBeenCalled(); - }); }); }); diff --git a/src/app/collapsible/collapsible.component.view.spec.ts b/src/app/collapsible/collapsible.component.view.spec.ts index 88fa3012..b0429dc3 100644 --- a/src/app/collapsible/collapsible.component.view.spec.ts +++ b/src/app/collapsible/collapsible.component.view.spec.ts @@ -1,5 +1,5 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { async, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, TestBed } from '@angular/core/testing'; import { buildComponent, MzTestWrapperComponent } from '../shared/test-wrapper'; import { @@ -28,21 +28,17 @@ describe('MzCollapsibleComponent:view', () => { let nativeElement: any; - function collapsible() { + function collapsible(): HTMLUListElement { return nativeElement.querySelector('.collapsible'); } - function collapsibleItem() { + function collapsibleItem(): HTMLLIElement { return collapsible().querySelector('li'); } - function forceSetTimeoutEnd() { - tick(1 * 1000000); // todo: understand why 1 is not working (setTimeOut is set to 0) - } - it('should display a collapsible', async(() => { - buildComponent(``).then((fixture) => { + buildComponent(``).then((fixture) => { nativeElement = fixture.nativeElement; fixture.detectChanges(); @@ -53,7 +49,7 @@ describe('MzCollapsibleComponent:view', () => { it('should have mode when provided', async(() => { - buildComponent(``).then((fixture) => { + buildComponent(``).then((fixture) => { nativeElement = fixture.nativeElement; fixture.detectChanges(); @@ -64,7 +60,7 @@ describe('MzCollapsibleComponent:view', () => { it('should have popout when provided', async(() => { - buildComponent(``).then((fixture) => { + buildComponent(``).then((fixture) => { nativeElement = fixture.nativeElement; fixture.detectChanges(); @@ -73,9 +69,32 @@ describe('MzCollapsibleComponent:view', () => { }); })); - it('should transclude collapsible item', fakeAsync(() => { + it('should be hidden when there is no collapsible items', async(() => { + + buildComponent<{ visible: boolean }>(` + + + + + `, { + visible: true, + }).then((fixture) => { + const component = fixture.componentInstance; + nativeElement = fixture.nativeElement; + fixture.detectChanges(); + + expect(collapsible().hasAttribute('hidden')).toBeFalsy(); + + component.visible = false; + fixture.detectChanges(); + + expect(collapsible().hasAttribute('hidden')).toBeTruthy(); + }); + })); + + it('should transclude collapsible item', async(() => { - buildComponent(` + buildComponent(` @@ -85,8 +104,6 @@ describe('MzCollapsibleComponent:view', () => { nativeElement = fixture.nativeElement; fixture.detectChanges(); - forceSetTimeoutEnd(); - const transcludeContentBody = collapsibleItem().querySelector('.collapsible-body'); const transcludeContentHeader = collapsibleItem().querySelector('.collapsible-header'); diff --git a/src/app/shared/remove-component-host/remove-component-host.ts b/src/app/shared/remove-component-host/remove-component-host.ts index a6eec341..fcb7dc53 100644 --- a/src/app/shared/remove-component-host/remove-component-host.ts +++ b/src/app/shared/remove-component-host/remove-component-host.ts @@ -2,23 +2,30 @@ import { AfterViewInit, ElementRef, Inject, + OnDestroy, } from '@angular/core'; -export abstract class MzRemoveComponentHost implements AfterViewInit { +export abstract class MzRemoveComponentHost implements AfterViewInit, OnDestroy { + + private childrenElement: HTMLElement[] = []; + private parentElement: HTMLElement; constructor( @Inject(ElementRef) public elementRef: ElementRef, ) { } ngAfterViewInit() { - const nativeElement: HTMLElement = this.elementRef.nativeElement; - const parentElement: HTMLElement = nativeElement.parentElement; + const hostElement = this.elementRef.nativeElement; + this.parentElement = hostElement.parentElement; - // Move all children out of the element - while (nativeElement.firstChild) { - parentElement.insertBefore(nativeElement.firstChild, nativeElement); + // move child out of the host element + while (hostElement.firstChild) { + this.childrenElement.push(this.parentElement.insertBefore(hostElement.firstChild, hostElement)); } - // Remove the empty element(the host) - parentElement.removeChild(nativeElement); + } + + ngOnDestroy() { + // remove moved out element + this.childrenElement.forEach(childElement => this.parentElement.removeChild(childElement)); } } diff --git a/src/app/shared/remove-component-host/remove-component-host.view.spec.ts b/src/app/shared/remove-component-host/remove-component-host.view.spec.ts index 679cae01..56343e7a 100644 --- a/src/app/shared/remove-component-host/remove-component-host.view.spec.ts +++ b/src/app/shared/remove-component-host/remove-component-host.view.spec.ts @@ -1,5 +1,4 @@ import { Component, ElementRef } from '@angular/core'; - import { async, TestBed } from '@angular/core/testing'; import { buildComponent, MzTestWrapperComponent } from '../../shared/test-wrapper'; @@ -26,7 +25,7 @@ class MzTestRemoveHostComponent extends MzRemoveComponentHost { describe('MzRemoveComponentHost:view', () => { - let nativeElement: any; + let nativeElement: HTMLElement; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -39,34 +38,69 @@ describe('MzRemoveComponentHost:view', () => { describe('ngOnInit', () => { - it('should move children out of the component and delete the component tag', async(() => { + it('should move children out of the component and keep the component tag', async(() => { - buildComponent( + buildComponent( ``, ).then(fixture => { fixture.detectChanges(); nativeElement = fixture.nativeElement; + const divListElement = nativeElement.querySelector('div.list') as HTMLElement; + const divEmptyElement = nativeElement.querySelector('div.empty') as HTMLElement; + const mzTestRemoveHost = nativeElement.querySelector('mz-test-remove-host') as HTMLElement; + + // children has been moved out and component tag kept + expect(nativeElement.childElementCount).toBe(3); + expect(divListElement).toBeTruthy(); + expect(divEmptyElement).toBeTruthy(); + expect(mzTestRemoveHost).toBeTruthy(); - expect(nativeElement.querySelector('z-test-remove-host')).toBeNull(); - expect(nativeElement.querySelector('div.list')).toBeTruthy(); + // component tag is empty + expect(mzTestRemoveHost.childElementCount).toBe(0); - const firstLiElement: HTMLElement = nativeElement.querySelector('div.list ul li.one'); + // children content has been moved correctly + const firstLiElement = divListElement.querySelector('ul li.one') as HTMLElement; expect(firstLiElement).toBeTruthy(); expect(firstLiElement.innerText).toBe('One'); - const secondLiElement: HTMLElement = nativeElement.querySelector('div.list ul li.two'); + const secondLiElement = divListElement.querySelector('ul li.two') as HTMLElement; expect(secondLiElement).toBeTruthy(); expect(secondLiElement.innerText).toBe('Two'); - const threeLiElement: HTMLElement = nativeElement.querySelector('div.list ul li.three'); - expect(threeLiElement).toBeTruthy(); - expect(threeLiElement.innerText).toBe('Three'); + const thirdLiElement = divListElement.querySelector('ul li.three') as HTMLElement; + expect(thirdLiElement).toBeTruthy(); + expect(thirdLiElement.innerText).toBe('Three'); - const divEmptyElement: HTMLElement = nativeElement.querySelector('div.empty'); expect(divEmptyElement).toBeTruthy(); + expect(divEmptyElement.childElementCount).toBe(0); expect(divEmptyElement.innerText).toBe('Empty'); }); })); }); + + describe('ngOnDestroy', () => { + + it('should remove moved out element', async(() => { + + buildComponent<{ visible: boolean }>( + ``, + { visible: true }, + ).then(fixture => { + nativeElement = fixture.nativeElement; + fixture.detectChanges(); + + const mzTestRemoveHost = () => nativeElement.querySelector('mz-test-remove-host') as HTMLElement; + + expect(mzTestRemoveHost()).toBeTruthy(); + expect(nativeElement.childElementCount).toBe(3); + + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + expect(mzTestRemoveHost()).toBeFalsy(); + expect(nativeElement.childElementCount).toBe(0); + }); + })); + }); }); diff --git a/src/app/sidenav/sidenav-collapsible/sidenav-collapsible.component.view.spec.ts b/src/app/sidenav/sidenav-collapsible/sidenav-collapsible.component.view.spec.ts index fb4d2002..4c1347d7 100644 --- a/src/app/sidenav/sidenav-collapsible/sidenav-collapsible.component.view.spec.ts +++ b/src/app/sidenav/sidenav-collapsible/sidenav-collapsible.component.view.spec.ts @@ -58,7 +58,7 @@ describe('MzSidenavCollapsibleComponent:view', () => { const li = collapsible.children[0]; expect(li.nodeName).toBe('LI'); expect(li.classList.length).toBe(0); - expect(li.children.length).toBe(2); + expect(li.children.length).toBe(3); // collapsible-header const collapsibleHeader = li.children[0]; @@ -68,8 +68,13 @@ describe('MzSidenavCollapsibleComponent:view', () => { expect(collapsibleHeader.classList).toContain('waves-effect'); expect(collapsibleHeader.innerHTML).toBe('some-text'); + // mz-sidenav-collapsible-header + const mzSidenavCollapsibleHeader = li.children[1]; + expect(mzSidenavCollapsibleHeader.nodeName).toBe('MZ-SIDENAV-COLLAPSIBLE-HEADER'); + expect(mzSidenavCollapsibleHeader.children.length).toBe(0); + // collapsible-body - const collapsibleBody = li.children[1]; + const collapsibleBody = li.children[2]; expect(collapsibleBody.nodeName).toBe('DIV'); expect(collapsibleBody.classList.length).toBe(1); expect(collapsibleBody.classList).toContain('collapsible-body');