3

I found a weird issue involving bootstrapjs modals along with angular2. I have a component that is bound to the html of an bootstrap modal:

// ... lots of imports here

@Component({
selector: 'scrum-sessions-modal',
bindings: [ScrumSessionService, ScrumSessionRepository, ScrumModuleDataContext, ElementRef]

})

@View({
templateUrl: 'templates/modals/scrumSessions.modal.html',

styleUrls: ['']
})

export class ScrumSessionsModalComponent {

    private _elementRef: ElementRef;
    public sessions: ScrumSession[];

    constructor(scrumSessionsProxy: ScrumSessionsProxy, scrumSessionService: ScrumSessionService, elementRef: ElementRef) {

        this._proxy = scrumSessionsProxy;
        this._scrumSessionService = scrumSessionService;
        this._elementRef = elementRef;

        // This is the bootstrap on shown event where i call the initialize method
        jQuery(this._elementRef.nativeElement).on("shown.bs.modal", () => {
            this.initialize();    
        });

         jQuery("#scrumSessionsModal").on("hidden.bs.modal", () => {
             this.cleanup();
         });

    }

    public initialize() {

        // Start our hub proxy to the scrumsessionshub
        this._proxy.start().then(() => {
            // Fetch all active sessions;
            this._scrumSessionService.fetchActiveSessions().then((sessions: ScrumSession[]) => {
                // This console.log is being shown, however the sessions list is never populated in the view even though there are active sessions!
                console.log("loaded");
                this.sessions = sessions;
            }); 
        });
    }

    // Free up memory and stop any event listeners/hubs
    private cleanup() {
        this.sessions = [];
        this._proxy.stop();
    }

}

In this modal i have an event listener in the constructor that checks when the modal is shown. when it does it calls the initialize function that will load the initial data that will be displayed in the modal.

The html for the modal looks like this:

<div class="modal fade" id="scrumSessionsModal" tabindex="-1" role="dialog" aria-labelledby="scrumSessionsModalLabel">
<div class="modal-dialog" role="document">
    <div class="modal-content">
    <div class="modal-header">
        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
        <h4 class="modal-title" id="myModalLabel">Actieve sessies </h4>
    </div>
    <div class="modal-body">

        <table class="table">
            <tr>
                <th>Id</th>
                <th>Maker</th>
                <th>Start datum</th>
                <th>Aantal deelnemers</th>
            </tr>
            <tr *ngFor="#session of sessions">
                <td>
                    {{ session.id }}
                </td>
                <td>
                    test
                </td>
                <td>
                    test
                </td>
                <td>
                test
                </td>
            </tr>
        </table>

    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
    </div>
    </div>
</div>

The problem i am having is that the sessions variable that is filled by the fetchactivesessions function does not get updated in the view. I put a debugger on the fetch completion method and it is being called with a result of one scrumsession object.

However, when i remove the on("shown.bs.modal") event listener and simply put the call to initialize() inside a setTimeout function of 5 seconds and then open the modal the sessions list is correctly populated.

It only happens when i put the initialize call inside the callback of the bootstrap shown event. It is like it stops checking for changes when the call is inside a jquery callback. Does anyone know what the deal is here? I could offcourse just call the initialize in the constructor but i rather have it called on the shown event from bootstrap.

I have published the latest version of the app for debugging at: http://gbscrumboard.azurewebsites.net/

Jurgen Welschen
  • 871
  • 1
  • 13
  • 21
  • Can you check if the `this` where `this.sessions = sessions;` is, actually refers to a `ScrumSessionsModalComponent` instance? – Poul Kruijt Dec 28 '15 at 14:33
  • I have published the latest version and placed a debugger on the fetch promise. http://gbscrumboard.azurewebsites.net/ . (click on the active sessions button). there you can see it is the correct instance (atleast from what i can see) – Jurgen Welschen Dec 28 '15 at 14:47
  • Have you tried using Observables to listen for events? Like `Observable.fromEvent(this._elementRef.nativeElement, 'shown.bs.modal')` – Eric Martinez Dec 28 '15 at 14:59
  • I have not heard about that before. I have tried it as follows: `var obs = Observable.fromEvent(this._elementRef.nativeElement, 'shown.bs.modal'); obs.forEach((value: any) => { this.initialize(); }, null);` however this did not update the view, but perhaps i am using it wrong. – Jurgen Welschen Dec 28 '15 at 15:21
  • It sounds like a Zone issue -- Angular doesn't know about the jQuery callback so it can't monkey patch it. See if this answer helps: http://stackoverflow.com/a/34376204/215945 – Mark Rajcok Dec 28 '15 at 20:23
  • Thank you mate that was it! – Jurgen Welschen Jan 01 '16 at 18:04

3 Answers3

6

Solution: Observing Bootstrap Events with Angular4 / Bootstrap4.

Summary: Intercept the bootstrap events, and fire off a custom event in its place. The custom event will be observable from within angular components.

Index.html:

<body>
...
<script>
  function eventRelay(bs_event){
  $('body').on(bs_event, function($event){
    const customEvent = document.createEvent('Event');
    customEvent.initEvent(bs_event, true, true);
    let target_id = '#'+$event.target.id;
    $(target_id)[0].dispatchEvent(customEvent);
  });
</script>
</body>

In your Component/Service:

//dynamically execute the event relays
  private registerEvent(event){
    var script = document.createElement('script');
    script.innerHTML = "eventRelay('"+event+"');"
    document.body.appendChild(script);
  }

In your Component's Constructor or ngOnInit(), you can now register and observe the bootstrap events. Eg, Bootstrap Modal 'hidden' event.

constructor(){
    registerEvent('hidden.bs.modal');
     Observable.fromEvent(document, 'hidden.bs.modal')
     .subscribe(($event) => {
       console.log('my component observed hidden.bs.modal');
       //...insert your code here...
    });
}

Note: the 'eventRelay' function must be inside index.html so that the DOM loads it. Otherwise it will not be recognized when you issue the calls to 'eventRelay' from within 'registerEvent'.

Conclusion: This is a middle-ware workaround solution that works with vanilla Angular4/Bootstrap4. I don't know why bootstrap events are not visible within angular, and I have not found any other solution around this.

Note1: Only call registerEvent once for each event. That means 'once' in the entire app, so consider putting all registerEvent calls in app.component.ts. Calling registerEvent multiple times will result in duplicate events being emitted to angular.

Note2: There is an alternative bootstrap framework you can use with angular called ngx-bootstrap (https://valor-software.com/ngx-bootstrap/#/) which might make the bootstrap events visible, however I have not tested this.

Advanced Solution: Create an Angular Service that manages the events registered through 'registerEvent', so it only only call 'registerEvent' once for each event. This way, in your components you can call 'registerEvent' and then immediately create the Observable to that event, without having to worry about duplicate calls to 'registerEvent' in other components.

ObjectiveTC
  • 2,477
  • 30
  • 22
3

Solved it with the link provided by Mark. Apparently angular does not know about the bootstrap events and i have to tell zone.js to manually trigger change detection.

this._elementRef = elementRef;
jQuery(this._elementRef.nativeElement).on("shown.bs.modal", () => {
    this._zone.run(() => {
      this.initialize();    
    });
});
Jurgen Welschen
  • 871
  • 1
  • 13
  • 21
  • Doesn't seem to work with Angular4 / Bootstrap4. Tried your answer here as well as the 'observable' version above, but neither of them results in Angular4 receiving the 'shown.bs.modal' event from Bootstrap4. – ObjectiveTC Sep 21 '17 at 21:36
  • yeah this answer is pretty outdated now. This was still in the angular 2 beta phase. I dont know how this would be converted to angular 4. – Jurgen Welschen Sep 22 '17 at 10:51
  • I found a solution. Intercept the event from within a script in index.html; construct a customEvent having the same event-name as the bootstrap event, and emit the event from the bootstrap event's original target. Doing this allows you to Observe the event from within your components. – ObjectiveTC Sep 27 '17 at 22:19
3

Wrapped above solution with an easy-to-use-service:

Update: If you are using rxjs-6 there are breaking changes, you will have to

import { fromEvent } from 'rxjs';
..

and use

..
fromEvent(document, BsEventsService.BS_COLLAPSIBLE_SHOWN);

instead of

Observable.fromEvent(document, BsEventsService.BS_COLLAPSIBLE_SHOWN);

bs-events.service.ts

import { Subject, Observable, Subscription } from 'rxjs';
import { Injectable } from '@angular/core';

@Injectable() export class BsEventsService {   private static BS_COLLAPSIBLE_SHOWN: string = 'shown.bs.collapse';   private static BS_COLLAPSIBLE_HIDDEN: string = 'hidden.bs.collapse';

  constructor() {
    this.registerEvent(BsEventsService.BS_COLLAPSIBLE_SHOWN);
    this.registerEvent(BsEventsService.BS_COLLAPSIBLE_HIDDEN);
  }

  onCollapsibleShown(): Observable<Event> {
      return Observable.fromEvent(document, BsEventsService.BS_COLLAPSIBLE_SHOWN);
  }   
  onCollapsibleHidden(): Observable<Event> {
      return Observable.fromEvent(document, BsEventsService.BS_COLLAPSIBLE_HIDDEN);
  }

  private registerEvent(event) {
    var script = document.createElement('script');
    script.innerHTML = "eventRelay('" + event + "');"
    document.body.appendChild(script);
  }

}

index.html

<app-root></app-root>
..
  <!-- relay bootstrap events to angular - start -->
  <script>
    function eventRelay(bs_event) {
      $('body').on(bs_event, function ($event) {
        const customEvent = document.createEvent('Event');
        customEvent.initEvent(bs_event, true, true);
        let target_id = '#' + $event.target.id;
        $(target_id)[0].dispatchEvent(customEvent);
      });
    }
  </script>
  <!-- relay bootstrap events to angular - end -->
..
</body>

component

import { BsEventsService } from './service/bs-events.service';
import { Subject, Observable, Subscription } from 'rxjs';
..

private onCllapsibleShownSubject: Subscription;
private onCllapsibleHiddenSubject: Subscription;

..
constructor(.., private bsEventsService: BsEventsService) {
..
}

ngOnInit() {
    this.onCllapsibleShownSubject = this.bsEventsService.onCollapsibleShown().subscribe((event: any) => {
      console.log('shown: ' + event.target.id);
    });
    this.onCllapsibleHiddenSubject = this.bsEventsService.onCollapsibleHidden().subscribe((event: any) => {
      console.log('hidden: ' + event.target.id);
    });
}
ngOnDestroy() {
    this.onCllapsibleShownSubject.unsubscribe();
    this.onCllapsibleHiddenSubject.unsubscribe();
}
Anand Rockzz
  • 6,072
  • 5
  • 64
  • 71
  • I look back at my answer and feels the same.. but I wasn't the same a year back and certainly angular is not the same either. Changed my approach, [here](https://stackoverflow.com/a/46704202/234110) is a better solution to hide/show bootstrap modals. – Anand Rockzz Aug 16 '19 at 21:10