I’m awed almost daily by the simplicity and elegance of Angular.js. By eliminating all of the DOM access syntax we’ve come to take for granted in jQuery and friends, and by giving any element on the page a live, two-way data binding relationship with your business logic, Angular lets you create anything from simple widgets to full-on Single Page Applications with the fewest lines of code possible.
I recently created a live GPA calculator as part of a large SPA I’m working on in my day job, but have boiled it down to its bare essence for this widget demo. Try changing the dropdown options here and watch the GPA calculation change in real-time:
In this example, we assume that a student’s current course load comes in over the wire with course names and units. We iterate over the course set and, for all courses being taken for a letter grade, multiply the numeric weight of a predicted grade by the number of units. Those scores get added up, then divided by the total number of units. When a new grade estimate is selected from a dropdown, we need to recalculate the whole aggregate. Let’s step through it.
In the controller, we put data for the course set and a mapping of letter grades to weights onto the current $scope:
// Letter grades and their weights for the dropdowns $scope.gradeopts = [ {grade: 'A', weight: 4}, {grade: 'A-', weight: 3.7}, {grade: 'B+', weight: 3.3}, {grade: 'B', weight: 3}, {grade: 'B-', weight: 2.7}, {grade: 'C+', weight: 2.3}, {grade: 'C', weight: 2}, {grade: 'C-', weight: 1.7}, {grade: 'D+', weight: 1.3}, {grade: 'D', weight: 1}, {grade: 'D-', weight: 0.7}, {grade: 'F', weight: 0} ]; // The student's courses, units and grading options. // This would typically come in as JSON data, retrieved via angular's $http $scope.schedule = [ { "course_number": "BIO 1A", "units": "3.0", "grade_option": "Letter" }, { "course_number": "COMPSCI 200", "units": "4.0", "grade_option": "Letter" }, { "course_number": "PHIL 201", "units": "3.0", "grade_option": "Letter" }, { "course_number": "ENG 11B", "units": "4.0", "grade_option": "P/NP" }, { "course_number": "HIST 231", "units": "2.5", "grade_option": "Letter" } ];
And here’s the HTML that transforms that data into UI, all wrapped in a div with an “ng-controller” attribute
<div ng-controller="GpaController">... </pre> which ties the fragment to a named Angular controller. <pre class="brush: xml; gutter: true; first-line: 1; highlight: []; html-script: false"> <table> <tr> <th>Class</th> <th>Units</th> <th>Grade</th> </tr> <tr ng-repeat="course in schedule"> <td>{{course.course_number}}</td> <td>{{course.units}}</td> <td> <!-- In this cell we EITHER show a dropdown for a graded class OR an uneditable display of the grading option. Note ng-options for converting gradeopts array into dropdown. We only show the picklist if the grading option is "Letter grade" (skip pass/no pass classes). When a new grade is selected, trigger recalculation of the whole set. See http://docs.angularjs.org/api/ng.directive:select --> <select ng-show="course.grade_option=='Letter'" ng-model="course.estimated_grade" ng-change="gpaUpdateCourse(course, course.estimated_grade)" ng-options="g.weight as g.grade for g in gradeopts"> </select> <!-- For non-letter-grade classes, just display the grading type --> <span ng-show="course.grade_option!='Letter'">{{course.grade_option}}</span> </td> </tr> </table>
There are some interesting bits in that select
element. ng-show
ensures that a dropdown will not be displayed if the course is not being taken for a letter grade.
ng-model
binds the dropdown to a property of the course object. But if you look at the data source above, you’ll notice that there is no estimated_grade
property. That’s because we added that property to each course when the app was first loaded (we’ll get to that in a moment).
And what is the value of that estimated grade? That’s where ng-options
comes in. It steps through the gradeopts
array and sets values and labels for each of the select
options. There’s a gotcha here – if you inspect the DOM and look at the generated select
elements, you’ll see that they seem to have simple integer values, not the actual weights we handed them. And yet, when the code is run, g.weight
will be substituted in automatically. Angular does this substitution so it can guarantee uniqueness of values and keep track of everything internally. Don’t fight it :)
ng-change
is fired whenever a dropdown is changed by the user. It fires the gpaUpdateCourse()
function in our controller, which receives the course object and the newly selected grade estimate.
Finally, we display the computed result handed back from Angular, and run it through the built-in “number” directive to limit display of the final estimated GPA to two decimal points. We also provide some fallback text for students who have no classes for some reason.
<!-- Use Angular's "number" directive to restrict GPA display to decimals || 'N/A' catches the outside case where the student has no letter grade classes and therefore no estimated_gpa. --> <p class="result">Estimated GPA: {{(estimated_gpa | number:2) || 'N/A'}}</p> <!-- In case student has no classes at all... --> <p ng-show="!schedule.length"> To calculate your GPA, you must be enrolled in one or more classes for the selected semester. </p>
In the controller, we have three functions:
First, an initializer to loop through the courses and add an estimated_grade
property to each:
$scope.gpaInit = function() { // On init, be generous... start everyone off with a 4.0 angular.forEach($scope.schedule, function(course) { course.estimated_grade = 4; }); $scope.gpaCalculate(); }(); // Note "()" - makes gpaInit() a self-running function, so it fires on page load
After populating estimated_grade
for each course, it kicks off gpaCalculate
:
$scope.gpaCalculate = function() { // Recalculate GPA on every dropdown change. var total_units = 0; var total_score = 0; angular.forEach($scope.schedule, function(course) { // Don't calculate for pass/no-pass courses! // Cast estimates and units to integers before doing math. if (course.grade_option === 'Letter') { course.score = parseFloat(course.estimated_grade, 10) * course.units; total_units += parseFloat(course.units, 10); total_score += course.score; } }); // The standard GPA calculation formula. estimated_gpa is // bound to the final result paragraph in the html $scope.estimated_gpa = total_score / total_units; };
The logic there is pretty straightforward. And finally, an update function which is triggered on every dropdown change:
$scope.gpaUpdateCourse = function(course, estimated_grade) { // When a new selection is made, update course object on scope // and trigger recalculation of overall GPA course.estimated_grade = estimated_grade; $scope.gpaCalculate(); };
And that’s it! A more interesting version of this might take recorded GPAs from previous school terms and calculate an aggregate GPA.
Rather than implicitly call a recalculate function when you know an underlying value changes, could you define the GPA as a scope function which returns that calculation result based on scope elements, thus letting angular automatic decide when to recalculate?
James, I suppose you could, though I’m not sure I see what the advantage would be.
Also, for correctness – recalculate is called explicitly, not implicitly.