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">×</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/
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();
}
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.
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();
});
});