Angular is an app-design framework and development platform for creating efficient and sophisticated apps.
Currently, Oppia is in a hybrid state where we have both Angular and AngularJS. This makes our application slow and bulky. The codebase has duplicate libraries since many of the AngularJS libraries are not compatible with Angular. This project aims to migrate the entire codebase to Angular. The benefits of doing this are:
-
Improved Developer Experience:
- Developing when the application is a hybrid state opens us to a whole host of complicated errors which are in some cases not solvable.
- Angular is being actively maintained and comes out with a lot of new features that aid development.
-
Improved User Experience:
- When the codebase is completely migrated, the developers will focus their efforts on making new features for the website rather than fixing nasty errors that pop up because of the hybrid state.
- Decreased page loading times as a result of not bundling AngularJS anymore.
- Better application performance in general.
The project plan will be iterative in nature. We will migrate the services first and then the controllers and directives. The services will be migrated in dependency order. For example, if A depends on B and B depends on C, we will migrate in the order C, B, and then A.
Note: Angular Migration Pull Requests must be accompanied with a video showing the before and after effects of their change to ensure that nothing is broken. This ensures faster review and a lower risk of reverted PRs
The angular migration tracker holds the record of which services are to be migrated. The issue #8472 holds a subset of those services that can be migrated without any major blockers.
-
Import the following dependencies:
import { downgradeInjectable } from '@angular/upgrade/static'; import { Injectable } from '@angular/core';
-
If the services uses
$http
, importHttpClient
as a dependency, also import:import { HttpClient } from '@angular/common/http';
-
Change the AngularJS factory definition to and Angular class definition as follows:
angular.module('oppia').factory('ServiceName',['dependency1', function(dependency1) {
to
import { dependency1 } from ... // to be added at the top of the file .. .. export class ServiceName {
-
Add a decorator above the class definition:
@Injectable({ providedIn: 'root' }) export class ServiceName { ...
-
Add a constructor for the class and inject the dependencies:
constructor( private service1: Service1, private service2: Service2) {}
-
Change
$http.get
requests in the service as follows:(a) Change
$http.get
tothis.http.get
:$http.get(url).then(function(response) { dataDict = angular.copy(response.data);
to
this.http.get( url).toPromise().then( (response) => {
The
dataDict
is not required in Angular services. You can directly use theresponse
variable.(b) Search in the codebase for where the service is used to obtain results from get requests and change
response.data
toresponse
.(c) Return the
errorCallback
(the reject function) witherrorResponse.error.error
as follows:(errorResponse) => { errorCallback(errorResponse.error.error); }
(d) Add
$rootScope.$applyAsync()
in the controller/directive that is resolving the HTTP request similar to how it is added here. To do so, perform a global search in the codebase for the function with the HTTP request. For example, if the service isSkillBackendApiService
and the function in which the HTTP request is made isfetchSkill
, then search the codebase forSkillBackendApiService.fetchSkill
and add$rootScope.$applyAsync()
as follows:SkillBackendApiService.fetchSkill(...).then((...) => { //resolve function ... $rootScope.$applyAsync() //add here }, (...) => { ... //reject function } );
Do this for all functions that have
http
calls. -
Change
$http.put
or$http.post
requests as follows:(a) Change
$http.post/put
tothis.http.post/put
$http.post(url).then(function(response) { ...
to
this.http.post( url).toPromise().then( (response) => {...;
(b) Add
$rootScope.$applyAsync()
wherever the function with the HTTP request is used. For example, see the changes here. You can find usages of the function just like you found usages when migrating$http.get
calls in the previous step. -
If you are migrating a service that is named as
.*-backend-api.service.ts
, then please return a domain object and not a dict in thesuccessCallback
function. For example take a look at PR #9505, where the domain object is created via an object factory. You also need to change the piece of code where this response is used because the response is now a domain object instead of a dict. If there is no specific object factory to alter the response to a domain object, create one similar to how it is done in this change.Topic domain objects need to contain properties that are being read from the backend. Therefore, the topic domain object does not depend on the service being migrated, but rather the expected return value of the function. For example, in
SkillBackendApiService
, the functionfetchSkill
will clearly return aSkill
object. Note thatSkillObjectFactory.ts
already exists, so we don't need to create it. But if there is no corresponding Object Factory, you need to create one similar to howSkillObjectFactory
is created. Next, we take the response from the backend and instead ofsuccessCallback(response)
, we resolvesuccessCallback(SkillObjectFactory.createFromBackendDict(response))
. This passes the frontendSkill
object to functions that callfetchSkill
when the promise gets resolved.Since before you migrated the file, the calling functions were expecting a backend dict object, the references need to be changed as well. To do this, do a global search in the code-base for the function, e.g.
SkillBackendApiService.fetchSkill
and refactor the code inside the resolve function to reflect that the parameter is now aSkill
object and not a backend dict object.Please note that interfaces/properties in Object Factories and the
.*-backend-api.service.ts
could be in snake_case. If that is the case, please surround them with single quotes as in'some_property'
. Except for these two categories, all the properties inside all other files should be camel case, e.g.someProperty
. -
For functions in the service, add type definitions for all the arguments as well as return values.
Note: For complex types or some type that is being used over functions or files we can declare an interface. For example in the file rating-computation.service.ts we have an export interface to declare the type
RatingFrequencies
. In the same file, we also have a function named static, which is used by the functions of the class itself. -
For functions which are private to the service (used as helper functions), add the private keyword.
-
At the end of the file, add:
angular.module('oppia').factory('ServiceName', downgradeInjectable(ServiceName));
For an example of migrating a service, see this pull request.
-
Remove all
beforeEach()
blocks and any other service that is not needed in the test file. -
Convert all the function keywords to fat arrow functions like this:
describe('abc', function() { ... });
to
describe('abc', () => { .. });
-
Import TestBed in your spec file
import { TestBed } from '@angular/core/testing'; import { ServiceName } from ...
If your test is for a service that makes HTTP requests, you also need to import the following:
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
-
Add a beforeEach block that creates an instance of service you want to test:
beforeEach(() => { serviceInstance = TestBed.get(ServiceName); });
(a) If your spec file needs any pipes (filters in angular), import them and add it to the providers in the TestBed configuration
beforeEach(() => { TestBed.configureTestingModule({ providers: [CamelCaseToHyphensPipe, ConvertToPlainTextPipe] // Any pipe that is required }); instance = TestBed.get(ServiceName);
(b) If your spec file tests a service that makes HTTP requests, you need to make an
HttpClientTestingModule
and add anafterEach
statement to check there are no pending requests after each test. For example:beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], }); httpTestingController = TestBed.get(HttpTestingController); instance = TestBed.get(ServiceName); }); afterEach(() => { httpTestingController.verify(); });
-
For each test, replace the name of the service with the instance name that is created above using TestBed.
-
If your spec file is for a service that makes HTTP requests then:
(a) Convert each individual test defined in it block as follows:
it('should ...', fakeAsync(() => { . . }));
(b) Change the test to create an HTTP request via httpTestingController. Here's an example from
topic-viewer-backend-api.service.spec.ts
:$httpBackend.expect('GET', '/topic_data_handler/0').respond( sampleDataResults); TopicViewerBackendApiService.fetchTopicData('0').then( successHandler, failHandler); $httpBackend.flush();
to
topicViewerBackendApiService.fetchTopicData('0').then( successHandler, failHandler); var req = httpTestingController.expectOne( '/topic_data_handler/0'); expect(req.request.method).toEqual('GET'); req.flush(sampleDataResults); flushMicrotasks();
There are two parts to this migration: the TS file and the HTML file.
Here are the steps to migrate the logic part.
Import Component
to the file:
import { Component } from '@angular/core';
Then take a look at the directive declaration. For example if the directive is declared like this:
angular.module('oppia').directive('conceptCard', [
'UrlInterpolationService', function(UrlInterpolationService) {
return {
restrict: 'E',
scope: {},
bindToController: {
getSkillIds: '&skillIds',
index: '='
},
templateUrl: UrlInterpolationService.getDirectiveTemplateUrl(
'/components/concept-card/concept-card.directive.html'),
controllerAs: '$ctrl',
controller: [
'$scope', '$filter', '$rootScope',
'ConceptCardBackendApiService', 'ConceptCardObjectFactory',
then add the following at the end of the file:
@Component({
selector: 'concept-card',
templateUrl: './concept-card.directive.html',
styleUrls: []
})
export class ConceptCardComponent {}
Some points to note:
- Please keep in mind the name of the directive declared. In this case, it is 'conceptCard'.
- The directive name converted to kebab-case is the selector (conceptCard -> concept-card).
- The name of the class is in CamelCase (note the first letter is capital) suffixed with "Component". So conceptCard -> ConceptCardComponent.
- The template of the directive (in 99% of cases) exists in the same folder as the directive.ts file.
Suppose the AngularJS code has the following dependencies:
controller: [
'$scope', '$rootScope',
'ConceptCardBackendApiService', 'ConceptCardObjectFactory',
The first two dependencies are interesting ones. They don't have a direct equivalent in Angular. In most cases, you will find something like $scope.someVariable =
, $scope.$onDestroy
, $scope.$onInit
, and $rootScope.$applyAsync()
. In other cases, contact @srijanreddy98.
Next, consider the other two dependencies ('ConceptCardBackendApiService', 'ConceptCardObjectFactory'). These are called injectables as they are "injected". These go into your constructor like this:
-
Import these two services into the directive.
import { ConceptCardBackendApiService } from 'service/some-service.ts'; import { ConceptCardObjectFactory } from 'services/some-other-service.ts';
-
Create a constructor for the class you made in the previous step and add those injectables there:
export class ConceptCardComponent { constructor( private conceptCardBackendApiService: ConceptCardBackendApiService, private conceptCardObjectFactory: ConceptCardObjectFactory ) {} }
Sometimes, you will also see dependencies like $window, $log, $timeout etc.
-
For
$window
: Usewindow-ref.service.ts
. In the constructor, with other dependencies, injectWindowRef
:constructor( ... private windowRef: WindowRef ) {}
Then instead of
window
, usewindowRef.nativeWindow
, e.g.windowRef.nativeWindow.location.href
. Also, instead oflocation
for setting URLs, uselocation.href
. -
For $timeout
: UsesetTimeout
-
For $log
: Uselogger.service.ts
Notice the casing very carefully. The service is ConceptCardBackendApiService but its instance is called conceptCardBackendApiService (with a small c)
You will notice that in almost every directive you have ctrl.$onInit
and/or ctrl.$onDestroy
. The equivalent of this in angular is ngOnInit
and ngOnDestroy
.
First, you need to import OnInit from '@angular/core'; Then implement it in the class by changing the class declaration to export class ConceptCard implements OnInit {
and adding an ngOnInit() {}
below the constructor. After this step the component class would look like this:
import { Component, OnInit } from '@angular/core';
...
export class ConceptCardComponent implements OnInit {
constructor(
private conceptCardBackendApiService: ConceptCardBackendApiService,
private conceptCardObjectFactory: ConceptCardObjectFactory
) {}
ngOnInit(): void {
...
}
}
Do the same for onDestroy
.
If there is some value in bindToController, then import Input
from @angular/core
;
There are four "syntaxes" that you could run into when trying to migrate bindToController:
'@'
'<'
'='
'&'
layoutType: '@',
will change to
@Input() layoutType: string;
Note that '@'
is always a string but '<'
can be of any type (string, number, object or custom types).
layoutAlignType: '<',
changes to
@Input() layoutAlignType: string;
(The type of layoutAlignType being string is an example. please be aware of the type used in your case).
Take a look at the directive name (in this case it is conceptCard). Now do a global search for e.g. <concept-card
(note the change to snake_case and the extra '<' at the beginning). In each search result, you should find something like layout-align-type=layoutAlign
.
- If the search result is in an AngularJS template: Change
layout-align-type=layoutAlign
to[layout-align-type]=layoutAlign
. Notice in the component it waslayoutAlignType
but in HTML it is [layout-align-type]. The camelCase to kebab-case change is required when the template is a template of an AngularJS component/directive.
Otherwise: Change layout-align-type=layoutAlign
to [layoutAlignType]=layoutAlign
.
If you find something like layout-align-type="center center"
, leave it as it is, do not change it. The []
syntax can only be used with variables. If the attribute is not surrounded by []
and the attribute value is surrounded by double quotes, that means the passed value is a string.
You will find '&'
with a syntax that looks like getSkillIds: '&skillIds',
. This requires some significant changes so please follow the next steps very carefully:
First, change the
getSkillIds: '&skillIds',
to
@Input() skillIds: Array<string>,
Note: If the syntax looks like skillIds: '&'
, then just change it to:
@Input() skillIds: Array<string>,
(Array<string>
is an example. please be aware of the type used in your case).
Next, change all cases of ctrl.getSkillIds()
to this.skillIds
. (Notice the parentheses were also removed.)
Take a look at the directive name (in this case it is conceptCard). Now do a global search for e.g. <concept-card
. (Note the change to kebab-case and the extra '<' at the beginning). In each search result, you should find something like skill-ids=skillIds
, which you should change to:
-
If the search result is in an AngularJS template (i.e. the corresponding .ts file for the HTML has not been migrated):
[skill-ids]=skillIds
. (Notice that in the component it wasskillIds
but in HTML it is [skill-ids]. The camelCase to kebab-case change is required when the template is a template of an AngularJS component/directive). -
Otherwise:
[skillIds]=skillIds
.
Note: If you find something like skill-type="supersonic"
, leave it as it is, do not change it. The []
syntax can only be used with variables. If the attribute is not surrounded by []
and the attribute value is surrounded by double quotes, that means the passed value is a string.
Understanding binding interpolation:
-
Binding is a way to share information between different directives using variables that pass values. Variables are passed via selectors of other directives present in the HTML file of the directive we are working on.
-
Interpolation is simply a way to make sure we pass variables and not values in our HTML. For example, how do we know if in
<dir-name abc=”xyz”>
,xyz
is a string or a variable? To clarify this ambiguity, we use interpolation for variables. Interpolation has 2 forms:<dir-name [abc]="xyz">
(The[]
indicates that the string is a variable)<dir-name abc="{{ xyz }}">
We use the first method in all cases except when the variable is interspersed with other text. e.g.
<dir-name abc="The boy has {{ count }} apples">
-
Do not use interpolation with properties marked with [prop], or events. These automatically assume that a variable is passed.
In most cases, the =
is the same as <
when looked at from an Angular2+ perspective, so just follow the steps given for <
migration.
Take a look at this example directive code:
var ctrl = this;
ctrl.isLastWorkedExample = function() {
return ctrl.numberOfWorkedExamplesShown ===
ctrl.currentConceptCard.getWorkedExamples().length;
};
ctrl.showMoreWorkedExamples = function() {
ctrl.explanationIsShown = false;
ctrl.numberOfWorkedExamplesShown++;
};
ctrl.$onInit = function() {
ctrl.conceptCards = [];
ctrl.currentConceptCard = null;
ctrl.numberOfWorkedExamplesShown = 0;
ctrl.loadingMessage = 'Loading';
ConceptCardBackendApiService.loadConceptCards(
ctrl.getSkillIds()
).then(function(conceptCardObjects) {
conceptCardObjects.forEach(function(conceptCardObject) {
ctrl.conceptCards.push(conceptCardObject);
});
ctrl.loadingMessage = '';
ctrl.currentConceptCard = ctrl.conceptCards[ctrl.index];
ctrl.numberOfWorkedExamplesShown = 0;
if (ctrl.currentConceptCard.getWorkedExamples().length > 0) {
ctrl.numberOfWorkedExamplesShown = 1;
}
// TODO(#8521): Remove when this directive is migrated to Angular.
$rootScope.$applyAsync();
});
};
Look at all lines matching the pattern ctrl.someVariable = function(...) {...
.
In the component you made in step one, just create those functions without the ctrl
and = function
:
export class ConceptCardComponent implements OnInit {
constructor(
private conceptCardBackendApiService: ConceptCardBackendApiService,
private conceptCardObjectFactory: ConceptCardObjectFactory
) {}
ngOnInit() {
}
isLastWorkedExample() {
}
showMoreWorkedExamples() {
}
}
Till now we only looked at ctrl.someVariable = function()
. Now we will look at all the other cases. Take a look at this directive code:
var ctrl = this;
ctrl.isLastWorkedExample = function() {
return ctrl.numberOfWorkedExamplesShown ===
ctrl.currentConceptCard.getWorkedExamples().length;
};
ctrl.showMoreWorkedExamples = function() {
ctrl.explanationIsShown = false;
ctrl.numberOfWorkedExamplesShown++;
};
ctrl.$onInit = function() {
ctrl.conceptCards = [];
ctrl.currentConceptCard = null;
ctrl.numberOfWorkedExamplesShown = 0;
ctrl.loadingMessage = 'Loading';
ConceptCardBackendApiService.loadConceptCards(
ctrl.getSkillIds()
).then(function(conceptCardObjects) {
conceptCardObjects.forEach(function(conceptCardObject) {
ctrl.conceptCards.push(conceptCardObject);
});
ctrl.loadingMessage = '';
ctrl.currentConceptCard = ctrl.conceptCards[ctrl.index];
ctrl.numberOfWorkedExamplesShown = 0;
if (ctrl.currentConceptCard.getWorkedExamples().length > 0) {
ctrl.numberOfWorkedExamplesShown = 1;
}
// TODO(#8521): Remove when this directive is migrated to Angular.
$rootScope.$applyAsync();
});
};
Looking at all the other ctrl.
declarations we find ctrl.numberOfWorkedExamplesShown
, ctrl.currentConceptCard
, ctrl.explanationIsShown
, ctrl.numberOfWorkedExamplesShown++``,
ctrl.conceptCards,
ctrl.loadingMessage`, etc.
Now we have to define them as class members. In order to do so just remove ctrl.
from the front of the variable and add them to the class above the constructor. For example:
export class ConceptCardComponent implements OnInit {
numberOfWorkedExamplesShown: number = 0;
currentConceptCard: ConceptCard;
explanationIsShown: boolean = false;
conceptCards: Array<ConceptCard>;
loadingMessage: string = '';
constructor(
private conceptCardBackendApiService: ConceptCardBackendApiService,
private conceptCardObjectFactory: ConceptCardObjectFactory
) {}
ngOnInit() {
}
isLastWorkedExample() {
}
showMoreWorkedExamples() {
}
}
Anything with ctrl.
becomes this.
. For example:
ctrl.isLastWorkedExample = function() {
return ctrl.numberOfWorkedExamplesShown ===
ctrl.currentConceptCard.getWorkedExamples().length;
};
becomes
isLastWorkedExample(): boolean {
return this.numberOfWorkedExamplesShown ===
this.currentConceptCard.getWorkedExamples().length;
}
Note the dependency injections also get the this.
prefix.
In the controller.$OnInit function we have:
ConceptCardBackendApiService.loadConceptCards(
ctrl.getSkillIds()
)
This will become:
this.conceptCardBackendApiService.loadConceptCards(
this.skillIds
)
Import downgradeComponent from '@angular/upgrade/static'. Then add the following downgrade statement to the end of the file:
angular.module('oppia').directive(
'conceptCard', downgradeComponent(
{component: ConceptCardComponent}));
Rename the file from *directive|controller.ts
to *component.ts
. Import this component into the corresponding module page and add it in the declarations
and entryComponents
. You can find the corresponding module page as follows:
- For directives in the pages folder, they will be in the same sub-folder as
*.module.ts
- For directives in the components folder, the module page is
shared-component.module.ts
This is the easier part of migration but still should be migrated carefully. Here are the migration patterns:
The interpolation in angular uses {{ }}
to interpolate. So change <[ ]>
to {{ }}
. For example, <li><[credit]></li>
becomes <li>{{ credit }} </li>
.
By default in Angular, all the variables of the class you migrated are available in HTML (unlike AngularJS where variables were prefixed by $ctrl or had to attached to the $scope). Remove all $ctrl.
from HTML. For example, <li ng-if="$ctrl.credits === 0"><[$ctrl.credits]></li>
becomes <li *ngIf="credits === 0"> {{ credit }} </li>
.
For example, <li ng-if="$ctrl.credits === 0"><[$ctrl.credits]></li>
becomes <li *ngIf="credits === 0"> {{ credit }} </li>
.
AngularJS | Angular2+ |
---|---|
<div class="oppia-about-credits-letter-groups three-col"> |
<div class="oppia-about-credits-letter-groups three-col"> |
<span ng-repeat-start="item in $ctrl.allCredits"> |
<div *ngFor="let credit of allCredits"> |
<[item.letter]></span> |
<span>{{ credit.letter }}</span> |
<ul ng-repeat-end> |
<ul> |
<li ng-repeat="credit in item.credits"><[credit]></li> |
<li *ngFor="let name of credit.names">{{ name }}</li> |
</ul> |
</ul> |
</div> |
</div> |
-
ng-cloak
: Remove. -
ng-class
: Change tongClass
. -
ng-show
/ng-hide
: Follow GeeksForGeeks.
If you see any HTML attribute which looks like <div editable=<[$ctrl.edit]>
, then just change it to <div [editable]="edit">
.
If you ng-src
/ng-srcset
, change it to [src]
/[srcset]
.
All the events in HTML are available in angular. Example onClick
becomes (click)
, ng-click
becomes (click)
, and ng-submit
becomes (ngSubmit)
.
You may come across the following:
<some-tag translate="I18N_VARIABLE_NAME"
translate-values="{abc: xyz}"></some-tag>
Convert it like this:
<some-tag [innerHTML]="'I18N_VARIABLE_NAME' | translate:{abc: xyz}">
</some-tag>
If there are no translate-values, simply use "'I18N_VARIABLE_NAME' | translate"
Please note the single-quote marks around I18N_VARIABLE_NAME
.
There may be some style updates required to make sure that the pages look exactly like before. You can find the changes here: https://github.com/oppia/oppia/pull/9980/files#diff-1d203da36aa74eef4c39b05a27eafbaeR40-R46. Besides this, styles that contain the directive name now need to be enclosed in a <div>
tag. For example compare this code from before migration to the migrated code.
-
Ensure your frontend tests pass
Python:
python -m scripts.run_frontend_tests
Docker:
make run_tests.frontend
Note: If your migrated service involves HTTP calls and when you run the frontend test your frontend test fail for some other service (One error that might pop is
Error: No pending request to flush !
) then go ahead and migrate the failing tests for the other service too. You might have guessed that in such a case we have migrated a service which is now making HTTP calls in Angular using HttpClient but some other service that is issuing HTTP requests to this service is still testing by making calls via AngularJS HTTP module (using $httpBackend). Go through this PR #9029, whereinquestion-creation.service
andquestion-backend-api.service
are migrated to Angular and we went ahead to change relevant tests inquestions-list.service.spec
. -
Ensure there are no typescript errors:
Python:
python -m scripts.typescript_checks
Docker:
make run_tests.typescript
-
Ensure there are no linting errors:
Python:
python -m scripts.linters.run_lint_checks
Docker:
make run_tests.lint
-
Test manually. See where the directive you have migrated is being used. You can do this by seeing where it's corresponding
selector
is being used. Then check whether functionality that you have implemented works as expected (like on the develop branch). Add a screen recording of the places where the directive is used when you open your PR!
The following imports will no longer be required:
import { downgradeInjectable } from '@angular/upgrade/static';
import { Injectable } from '@angular/core';
Change the file overview to not include the term Object Factory. Instead, replace it with the word "model".
For example:
Before | After |
---|---|
Factory for creating new frontend instances of ParamMetadata |
Model class for creating new frontend instances of ParamMetadata |
Locate the class in the file whose name is suffixed by ObjectFactory. Move all the functions from that ObjectFactory class (except the constructor) and add them to the other class in the file. Add static
in front of all the functions you moved.
Before:
export class ParamMetadata {
action: string;
paramName: string;
source: string;
sourceInd: string;
constructor(
action: string, paramName: string, source: string, sourceInd: string) {
this.action = action;
this.paramName = paramName;
this.source = source;
this.sourceInd = sourceInd;
}
}
@Injectable({
providedIn: 'root'
})
export class ParamMetadataObjectFactory {
createWithSetAction(
paramName: string, source: string, sourceInd: string): ParamMetadata {
return new ParamMetadata(
ExplorationEditorPageConstants.PARAM_ACTION_SET, paramName, source,
sourceInd);
}
createWithGetAction(
paramName: string, source: string, sourceInd: string): ParamMetadata {
return new ParamMetadata(
ExplorationEditorPageConstants.PARAM_ACTION_GET, paramName, source,
sourceInd);
}
}
After:
export class ParamMetadata {
action: string;
paramName: string;
source: string;
sourceInd: string;
constructor(
action: string, paramName: string, source: string, sourceInd: string) {
this.action = action;
this.paramName = paramName;
this.source = source;
this.sourceInd = sourceInd;
}
static createWithSetAction(
paramName: string, source: string, sourceInd: string): ParamMetadata {
return new ParamMetadata(
ExplorationEditorPageConstants.PARAM_ACTION_SET, paramName, source,
sourceInd);
}
static createWithGetAction(
paramName: string, source: string, sourceInd: string): ParamMetadata {
return new ParamMetadata(
ExplorationEditorPageConstants.PARAM_ACTION_GET, paramName, source,
sourceInd);
}
}
@Injectable({
providedIn: 'root'
})
export class ParamMetadataObjectFactory {
}
Next, remove the @Injectable and the object factory class. Specifically, remove the code that looks like this:
@Injectable({
providedIn: 'root'
})
export class ParamMetadataObjectFactory {
}
angular.module('oppia').factory(
'ParamMetadataObjectFactory',
downgradeInjectable(ParamMetadataObjectFactory));
Remove any references to the object factory from:
- angular-service.index.ts
- oppia-angular-root.component.ts
- UgradedServices.ts
The file you are working on will be named either *-object.factory.ts
or *ObjectFactory.ts
. You need to remove the object factory part and add .model.ts instead. For example, PlaythroughObjectFactory.ts
should be renamed to playthrough-object.model.ts and
skill-summary-object.factory.tsshould be renamed to
skill-summary.model.ts`.
Make sure to search the codebase for the function name to make sure you find all usages.
For example, let one of the functions that you moved before (in step 3) be createWithGetAction
.
First, make sure ParamMetadata
has been imported:
import ParamMetadata from param-metadata.model.ts
Next, change ParamMetadataObjectFactory.createWithGetAction()
to ParamMetadata.createWithGetAction(...)
. Do this for all functions, and then remove any other ParamMetadataObjectFactory
references left in the file.
Make sure that ParamMetadata has been imported. Then change this.paramMetadataObjectFactory.createWithGetAction(...)
to ParamMetadata.createWithGetAction(...)
. Do this for all functions, and remove paramMetadataObjectFactory
from the constructor.
Each ``-object.factory.tswill have its corresponding spec file named
-object.factory.spec.ts`. You will need to follow the procedure mentioned in step 6 (the previous step), to refactor the spec as well. Note that in spec file `ParamMetadataObjectFactory` could be shortened to `pmof`, so searching by the function name in the spec file will be more accurate.
PRs for reference: #10701, #10713.
- Front-end tests fail. This can for various reasons, but the most common one is return types. You will get errors like: ‘a’ is not defined on an object of type ‘X’. Try console logging the object you are receiving actually has the property you’re calling and adjust accordingly. This will mostly happen with HttpResponse objects.
-
Error like this:
'some-selector' is not a known element: 1. If 'some-selector' is an Angular component, then verify that it is part of this module. 2. 2. If 'some-selector' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.
This can occur for a couple of reasons:
- The corresponding external Angular module is not yet integrated into the codebase, e.g. For
ngModel
, you needFormsModule
. - It is another un-migrated directive. You need to wrap it in an Angular wrapper and import it into your current module. Do it via the shared component module. Use #9237 for reference
- The Angular module has a different selector i.e
md-card
becomesmat-card
- The corresponding external Angular module is not yet integrated into the codebase, e.g. For
There are two reasons:
- Our app is in hybrid state i.e. half Angular and half AngularJS, and we need to downgrade each of our services to AngularJS so that our application runs smoothly.
- To define a class as a service, Angular uses the @Injectable() decorator to provide the metadata that allows Angular to inject it into a component as a dependency. When we provide the service at the root level, Angular creates a single, shared instance of the service and injects it into any class that asks for it. Registering the provider in the @Injectable() metadata also allows Angular to optimize an app by removing the service from the compiled app if it isn't used.
As you can see in the example here, the directive updates the value when the promise is resolved:
TopicViewerBackendApiService.fetchTopicData(ctrl.topicName).then(
function(topicDataDict) {
ctrl.topicId = topicDataDict.topic_id;
ctrl.canonicalStoriesList = topicDataDict.canonical_story_dicts;
ctrl.degreesOfMastery = topicDataDict.degrees_of_mastery;
ctrl.skillDescriptions = topicDataDict.skill_descriptions;
ctrl.subtopics = topicDataDict.subtopics;
$rootScope.loadingMessage = '';
ctrl.topicId = topicDataDict.id;
Everything was working fine before the migration, but after migration, we noticed that all the values in the above-mentioned function were updated but not propagated to the corresponding HTML file. Searching online yielded a Stack Overflow post which mentions that:
Yes, AngularJS's bindings are "turn-based", they only fire on certain DOM events and on calls to
$applyAsync/$digest
. There are some useful services like$http
and$timeout
that do the wrapping for you, but anything outside of that requires calls to either$applyAsync
or$digest
._
The $digest
cycle is not running after we've upgraded $http
to HttpClient
, so we add $rootScope.$applyAsync
to explicitly ask Angular to propagate the changes to our HTML.
When a service has a dependent service, DI (dependency injector) finds or creates that dependent service. And if that dependent service has its own dependencies, DI finds or creates them as well. To quote from the Angular docs, "As a service tester, you must at least think about the first level of service dependencies but you can let Angular DI do the service creation and deal with constructor argument order when you use the TestBed testing utility to provide and create services."
-
What are Promises?
Promises are exactly what they sound like. In the simplest words, they are a promise to the developer that things will work, what to do when they work, and also when they don’t work.
They can have three states:
- Resolved: The caller of the promise has executed as expected.
- Rejected: The caller of the promise didn’t execute as expected
- Pending: The caller is yet to be executed
What exactly are the
resolve
andreject
that promises accept? They are simply function calls. For example,successCallback(abcd)
will give parameterabcd
to the resolve function when the promise caller is called.How is it structured? A promise is called using a
.then()
statement after the function. Note that you cannot put this after any function, only one that returns a promise. Then functions follow. If there is only one function, it is the resolve function. If there are two functions, they are called resolve and reject, respectively. The reject function is used mostly for error handling and unexpected behaviourFor more reading check out the MDN Guide!
-
What is
rootScope
?Angular has scopes.
$scope
is a local scope, which is bound to exactly one controller. It can local properties and functions of the controller.$rootScope
on the other hand, is the global scope and can be accessed from everywhere.What is
$rootScope.$applyAsync()
and why do we use it?$rootScope.$applyAsync
is used to update the global properties and variables so that the new state can be used by the function where it is called. As to why it is not updated automatically, the reason is that the Angular DOM basically runs in cycles, and apply causes the changes to be saved. Mostly this is done automatically, but in some cases, we have to do it explicitly. Remember that this function will go in the resolve of the promise! This is because we want the variables to get to their new state in case of expected behaviour.For more reference, see the AngularJS docs on scopes.
-
How do I assign types in the Angular file?
Follow a trail. Some are really simple, but they all follow the same pattern. Keep following the variable through different references to see what type to assign. You can even use other references of that variable. For example, if you wanted to assign a type to a function that returned
WindowRef.nativeWindow
, you would go to thewindow-ref.service
file and see thatnativeWindow
returned the_window
object which had a typeWindow
.Other times it might be obvious from the name. For example,
SkillList
is obviously an array of Skills. However, double-check these!The final way is to use a console log statement. This is good for complicated data types. Run the local development server and log the value whose type you want to know. You’ll see something of type Object with certain properties. Search for these properties in the codebase to find out what type it is!
-
What are fakeAsync() and flushMicrotasks() and why do we use them?
For this we need to understand why being synchronous is a problem for tests. Compilers don’t compile code line by line. Instead, they push processes into a queue as they come and the resulting processes are pushed once the first processes end. What this means is that a process whose parent was called earlier may be executed after another whose parent was called later due to how much time the parent processes took. To make an asynchronous function synchronous we use fakeAsync combined with flushMicrotasks. FakeAsync creates a fake asynchronous zone wherein you can control process flow. When flushMicrotasks is called, it flushes the task queues, i.e it waits for the processes to leave the queue before proceeding further. Then, the tests are consistent!
-
What are MockServices/FakeServices that are in the codebase?
MockServices are basically just used to imitate real services and provide functionality for tests via inorganically-made function copies of the service. These are faster but don’t test services, so be wary of using them. The reason we use them is that we want to want to test the current service, not the other service, so we just use a small shell to provide the functionality we want.
-
How can I have constants shared across Angular and AngularJS code?
The Angular 2+ constants file is named *.constants.ts whereas the AngularJS equivalents of those constants must be in a separate file named *.constants.ajs.ts. The constants must be first declared in the Angular constants file and then be declared in the corresponding AngularJS constants file by importing the constants class from the Angular constants file and using that class's properties to declare the AngularJS equivalents. Then import the AngularJS constants class in the module and add it to the
providers
list of theNgModule
.For example, if there is a constant named
SKILL_EDITOR_CONSTANT
that needs to be used in skill editor, then add that constant to theSkillEditorConstants
class of the file skill-editor-page.constants.ts like this:export class SkillEditorPageConstants { ... public static SKILL_EDITOR_CONSTANT = 'constant_value'; ... }
Now, add the constant to the AngularJS file as well:
import { SkillEditorPageConstants } from 'pages/skill-editor-page/skill-editor-page.constants.ts'; ... oppia.constant('SKILL_EDITOR_CONSTANT', SkillEditorPageConstants.SKILL_EDITOR_CONSTANT); ...
And now you can use the constant in both your AngularJS as well as Angular parts of the code!
For any queries related to angular migration, please don't hesitate to reach out to Srijan Reddy (@srijanreddy98).