Reputation: 3790
For an educational side-project I am working on, I want to avoid using AngularJS Material Design, UI Bootstrap, or any custom libraries that provide modal functionality.
However, I've hit a snag. I've created a service that is supposed to manage and dynamically create modals. It provides an open
function that accepts a spec object, which it then reproduces in the DOM.
What this code actually does:
1. The modal is correctly appended to the DOM.
2. The modal controller's $onInit
function fires.
What this code does not do:
1. Bind the $ctrl.message
property in the markup to the instance of the controller that we know starts.
Normally, I would ask my question after providing code, however there's a good bit of code required to reproduce this problem (it's below, sans some AngularJS boilerplate.) As that's the case, though, here's my question:
In what way can I get the modals being spun off by this service to properly bind their contents to their given controller?
What I've tried:
As you can see in ModalService.bindModalDiv
, I've tried a few avenues of thought, mostly using $compile
. Yet, $compile
and the resulting link function don't actually seem to be binding the new DOM elements to Angular.
I've tried using $controller
to explicitly bind the new scope being generated to the someModalCtrl
being instantiated, but that doesn't seem to help at all.
Because I can hit breakpoints on the someModalCtrl
, and see the console.log
message I used as a sanity check, I think I'm misunderstanding how exactly I'm supposed to bind the new DOM elements to Angular. I'm sure I'm missing something basic that I've managed to forget about or disregard, somehow.
One more note:
I'm sure my problems with getting the modal to bind to AngularJS properly aren't the only problems present. Please remember, I'm doing this partially as a learning excersize; if y'all can help me figure out my modal problem, I'll keep on doing my due diligence and hunting down the flaws I've doubtless built into this approach. Therefore, if you see something that's not a modal problem, it's OK to draw my attention to it, but I won't rewrite the question to fix whatever you find - unless it's absolutely essential that I do. As an example - I know that ModalService.open
has some issues in how I'm implementing the promise setup. A $rootScope.$watch
is probably more reasonable.
modalSvc.ts:
export interface IModalSpecObject {
parent?: string | Element | JQuery;
templateURL: string
controller: string;
controllerAs?: string;
data: object;
}
export class ModalInstance {
public isOpen: boolean = true;
public returnData: object = null;
public element: JQLite = null;
public $parent: JQuery = null;
public constructor(
public specObject: IModalSpecObject
) {
}
public close(returnData: object): void {
if (this.element)
this.element.remove();
this.isOpen = false;
this.returnData = returnData;
}
}
export class ModalService {
public pollRate: number = 250;
public instance: ModalInstance = null;
public static $inject: string[] = [
'$q', '$rootScope', '$compile', '$controller'
];
public constructor(
public $q: ng.IQService,
public $rootScope: ng.IRootScopeService,
public $compile: ng.ICompileService,
public $controller: ng.IControllerService
) {
}
public open(specObject: IModalSpecObject): ng.IPromise<{}> {
if (this.instance && this.instance.isOpen)
this.instance.close(null);
this.instance = new ModalInstance(specObject);
const $parent: JQuery = this.setParent(specObject);
const modalDiv: JQLite = this.buildModal(specObject);
this.bindModalDiv(modalDiv, $parent);
const result: ng.IPromise<{}> = this.$q((resolve) => {
setInterval(() => {
if (!this.instance.isOpen) {
resolve(this.instance.returnData);
}
}, this.pollRate);
});
return result;
}
private buildModal(specObject: IModalSpecObject): JQLite {
const modalDiv: JQLite = angular.element('<div/>');
modalDiv.addClass('modal');
const $modalPanel: JQuery = $('<div/>');
$modalPanel.addClass('modal-panel');
// Inject HTML template...
$modalPanel.load(specObject.templateUrl);
// Set up the angular controller...
const controllerAs: string = specObject.controllerAs
? specObject.controllerAs
: '$ctrl';
$modalPanel.attr('ng-controller', `${specObject.controller} as ${controllerAs}`);
modalDiv.append($modalPanel);
this.instance.element = modalDiv;
return modalDiv;
}
private setParent(specObject: IModalSpecObject): JQuery {
let $parent: JQuery;
if(!specObject.parent)
$parent = $(document);
else if (typeof specObject.parent === "string"
|| specObject.parent instanceof Element)
$parent = $(specObject.parent);
else if (specObject.parent instanceof jQuery)
$parent = specObject.parent;
else
$parent = $(document);
this.instance.$parent = $parent;
return $parent;
}
// !!!! !!!! I suspect this is where my problems lie. !!!! !!!!
private bindModalDiv(modalDiv: JQLite, $parent: JQuery): void {
const newScope: ng.IScope = this.$rootScope.$new(true);
// Try #1: Bind generated element to parent...
//$parent.append(this.$compile(modalDiv)(newScope));
// Try #1a: Generate bindings, then append to parent...
//const element: JQLite = this.$compile(modalDiv)(newScope);
//$parent.append(element);
// Try #2: Bind element to parent, then generate ng bindings...
//$parent.append(modalDiv);
//this.$compile(modalDiv)(newScope);
// Try #3: Well, what if we bind a controller to the scope?
const specObject: IModalSpecObject = this.instance.specObject;
const controllerAs: string = specObject.controllerAs
? specObject.controllerAs
: '$ctrl';
this.$controller(`${specObject.controller} as ${controllerAs}`, {
'$scope': newScope
});
const element = this.$compile(modalDiv)(newScope);
$parent.append(element);
}
}
angular
.module('app')
.service('modalSvc', ModalService);
SomeController.ts:
SomeController.ts
pretty much just controls a button to trigger the modal's appearance; I've not included the markup for that reason.
export class SomeController {
public static $inject: string[] = [ 'modalSvc' ];
public constructor(
public modalSvc: ModalService
) {
}
public $onInit(): void {
}
public openModal(): void {
const newModal: IModalSpecObject = {
parent: 'body',
templateUrl: '/someModal.html',
controller: 'someModalCtrl',
data: {
'message': 'You should see this.'
}
};
this.modalSvc.open(newModal)
.then(() => {
console.log('You did it!');
});
}
}
angular.module('app').controller('someCtrl', SomeController);
someModal.html:
<div class="modal-header">
Important Message
</div>
<!-- This should read, "You should see this." -->
<div class="modal-body">
{{ $ctrl.message }}
</div>
<!-- You should click this, and hit a breakpoint and/or close the modal. -->
<div class="modal-footer">
<button ng-click="$ctrl.close()">Close</button>
</div>
someModal.ts:
export class SomeModalController {
public message: string = '';
public static $inject: string[] = [ 'modalSvc' ];
public constructor(
public modalSvc: ModalService
) {
}
public $onInit(): void {
console.log('$onInit was triggered!');
this.message = this.modalSvc.instance.specObject.data['message'];
}
public close(): void {
this.modalSvc.instance.close(null);
}
}
angular
.module('app')
.controller('someModalCtrl', SomeModalController);
Upvotes: 1
Views: 198
Reputation: 3790
I figured out where I went wrong - I needed to use $().load()
's callback. JQuery load
is asynchronous, which meant that $compile
was working correctly; however, the HTML in my modal partial wasn't loaded by the time $compile
had done its job, thus the unbound HTML.
A slight modification of my ModalService
got around this, though.
Revised fragment of ModalSvc.ts:
// This is just a convenience alias for void functions. Included for completeness.
export type VoidFunction = () => void;
// ...
public open(specObject: IModalSpecObject): ng.IPromise<{}> {
if (this.instance && this.instance.isOpen)
this.instance.close(null);
this.instance = new ModalInstance(specObject);
const $parent: JQuery = this.setParent(specObject);
// open already returned a promise before, we just needed to return
// the promise from build modal, which in turn sets up the true
// promise to resolve.
return this.buildModal(specObject)
.then((modalDiv: JQLite) => {
this.bindModalDiv(modalDiv, $parent);
const result: ng.IPromise<{}> = this.$q((resolve) => {
// Also, side-note: to avoid resource leaks, always make sure
// with these sorts of ephemeral watches to capture and release
// them. Resource leaks are _no fun_!
const unregister: VoidFunction = this.$rootScope.$watch(() => {
this.instance.isOpen
}, () => {
if (! this.instance.isOpen) {
resolve(this.instance.returnData);
unregister();
}
});
});
return result;
});
}
private buildModal(specObject: IModalSpecObject): ng.IPromise<{}> {
const modalDiv: JQLite = angular.element('<div/>');
modalDiv.addClass('modal');
this.instance.element = modalDiv;
const $modalPanel: JQuery = $('<div/>');
$modalPanel.addClass('modal-panel');
// By wrapping $modalPanel.load in a $q promise, we can
// ensure that the modal is fully-built before we $compile it.
const result: ng.IPromise<{}> = this.$q((resolve, reject) => {
$modalPanel.load(specObject.templateUrl, () => {
modalDiv.append($modalPanel);
resolve(modalDiv);
});
});
return result;
}
private setParent(specObject: IModalSpecObject): JQuery {
let $parent: JQuery;
if(!specObject.parent)
$parent = $(document);
else if (typeof specObject.parent === "string"
|| specObject.parent instanceof Element)
$parent = $(specObject.parent);
else if (specObject.parent instanceof jQuery)
$parent = specObject.parent;
else
$parent = $(document);
this.instance.$parent = $parent;
return $parent;
}
private bindModalDiv(modalDiv: JQLite, parent: JQLite): void {
// parent should be a JQLite so I can use the injector() on it.
parent.injector().invoke(['$rootScope', '$compile', ($rootScope, $compile) => {
const newScope: ng.IScope = $rootScope.$new(true);
this.$controller(this.getControllerAsString(), {
'$scope': newScope
});
const element: JQLite = $compile(modalDiv)(newScope);
parent.append(element);
}]);
}
private getControllerAsString(): string {
const specObject: IModalSpecObject = this.instance.specObject;
const controllerAs: string = specObject.controllerAs
? specObject.controllerAs
: '$ctrl';
return `${specObject.controller} as ${controllerAs}`;
}
I figured this out by going back and doing the step-by-step engineering. I first ensured that $compile
was working by creating an element whose contents were {{ 2 + 2 }}
, compiling it, then injecting it. When I saw 4
added to my page, I knew that the compile-then-inject aspect of the program worked just fine.
From there, I started building up the modal's construction, and found it working flawlessly...up until I got to jQuery load
. When I read the documentation, I saw the error of my ways.
TL;DR: Read the friendly manual!
Upvotes: 1