Skip to content

Instantly share code, notes, and snippets.

@mjbradford89
Last active March 28, 2022 11:37
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • Save mjbradford89/e0d2175e1742173530966827184f5a21 to your computer and use it in GitHub Desktop.
Save mjbradford89/e0d2175e1742173530966827184f5a21 to your computer and use it in GitHub Desktop.
Angular2 in Liferay Portal 7

Angular2 in Liferay Portal 7

The following are current outstanding issues with this process that can make it difficult when using Angular2 inside Portal.

##Pain points:

  • SennaJS does not work correctly when navigating away from a page containing an Angular2 component. This is due to the way ZoneJS wraps the XMLHttpRequest.send method. See https://github.com/angular/zone.js/blob/master/dist/zone.js#L113. I'm not sure if there is a way around this.
  • Currently unable to use the liferay-amd-loader to load an angular project (at least not yet). Must use SystemJS module loader.
    • This should be possible, but I've not yet figured out exactly how yet.
  • Node modules must be copied into /src to allow for proper typescript compilation.
    • This should also be possible by configuring the TypeScript compiler in some way to look in node_modules.
  • Multiple instances/portlets using angular will break. I think this can be resolve by importing angular outside of the portlet, and bootstrapping Angular applications as they become available. Similar to this blog post: https://web.liferay.com/es/web/sampsa.sohlman/blog/-/blogs/trying-the-angularjs-with-liferay

##Steps for creating a Portlet that uses Angular2

  1. Create a Portlet skeleton using the Blade CLI. More information on this can be found here: https://dev.liferay.com/develop/tutorials/-/knowledge_base/7-0/creating-modules-with-blade-cli. For this tutorial I chose to create a module from the 'portlet' template. Your command should look something like the following blade create -d path/to/angular-portlet portlet

  2. Once you have a portlet skeleton, we can start creating the extra infrastructure we will need for Angular2. Lets create the following files in our angular-portlet/portlet directory. * package.json * gulpfile.js

  3. We need to create a task in our gulpfile that will compile our typescript into javascript. There are many ways to do this, but I used the gulp-typescript node package.

  4. Now that we have a task to compile our code, we need to make sure it gets called when we deploy the module. Inside our build.gradle we create a task of type ExecuteGulpTask, that depends on the npmInstall task, and calls our task we created in our gulp file.

  5. Now, lets import angular's dependencies in our init.jsp `<script src="https://npmcdn.com/zone.js@0.6.12?main=browser"></script>

<script src="https://npmcdn.com/reflect-metadata@0.1.3"></script>`
  1. Now lets create a simple Angular2 application that integrates with a service in Liferay. Please see the attached files.

  2. We can now create a way to load our application inside of your portlet. Most Angular2 applications are loaded with SystemJS, which is what we'll use here. There should be a way to use the liferay-amd-loader for this, but I haven't quire figured out how that will work yet.

So first, lets import SystemJS like this <script src="https://npmcdn.com/systemjs@0.19.27/dist/system.src.js"></script>. And we also have to create and import a SystemJS configuration file, like so <script src="/o/angular-portlet/js/system.config.js"></script>.

The next step is to load our application. This can be done from many places, but for my purposes I put it in our view.jsp like so: ``` aui:script System.import('app').then(function(module) { module.main(Liferay); }).catch(function(err){ console.error(err); }); </aui:script>


What's going on in this code is, SystemJS is trying to load 'app', which we have defined in our system.config.js to load our main.js file first.  the code `module.main(Liferay)` is calling our main function in main.js, and passing in the Liferay global variable.

In the end, our portlet should resemble this directory structure:

angular-portlet/ └─portlet ├── build.gradle ├── package.json ├── gulpfile.js ├── bnd.bnd ├── src/main/java/portlet/portlet/ │ │── AngularPortlet.java │ └── route/ | └── AngularPortletFriendlyURLMapper.java └── resources/META-INF/ ├── friendly-url-routes/ │ └── routes.xml └── resources/ ├── init.jsp ├── view.jsp └── js/ ├── system.config.js └── app/ ├── main.ts ├── app.component.ts ├── app.routes.ts ├── country.ts └── country-detail.component.ts

/**
* Copyright (c) 2000-present Liferay, Inc. All rights reserved.
*
* This library is free software; you can redistribute it and/or modify it under
* the terms of the GNU Lesser General Public License as published by the Free
* Software Foundation; either version 2.1 of the License, or (at your option)
* any later version.
*
* This library is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
* details.
*/
package com.liferay.calendar.web.internal.portlet.route;
import com.liferay.portal.kernel.portlet.DefaultFriendlyURLMapper;
import com.liferay.portal.kernel.portlet.FriendlyURLMapper;
import org.osgi.service.component.annotations.Component;
@Component(
immediate = true,
property = {
"com.liferay.portlet.friendly-url-routes=META-INF/friendly-url-routes/routes.xml",
"javax.portlet.name=AngularPortlet"
},
service = FriendlyURLMapper.class
)
public class AngularPortletFriendlyURLMapper extends DefaultFriendlyURLMapper {
}
import { Component, Inject } from '../node_modules/@angular/core';
import { Router, ROUTER_DIRECTIVES } from '../node_modules/@angular/router';
import { Country } from './country';
import { CountryDetailComponent } from './country-detail.component';
import { LocationService } from './location.service';
@Component({
selector: 'my-app',
styleUrls: [`/o/angular-portlet/styles/app.component.css`],
templateUrl: '/o/angular-portlet/templates/app.component.html',
directives: [ROUTER_DIRECTIVES, CountryDetailComponent],
providers: [LocationService]
})
export class AppComponent {
componentName: 'AppComponent';
title = 'This is an Angular Portlet inside Liferay Portal 7!';
label = 'Select a country to get more information about it.';
countries: Country[];
constructor(
@Inject(Router)private router: Router,
@Inject(LocationService) private locationService: LocationService,
@Inject('Liferay') private Liferay: any) {
this.getCountries();
}
getCountries() {
this.locationService.getCountries().then((countries) => {
this.countries = countries;
});
}
onChange(countryId:number) {
this.locationService.getRegions(countryId).then(() => {
this.router.navigate(['-/angular/country', countryId]);
});
}
}
import { provideRouter, RouterConfig } from '../node_modules/@angular/router';
import { CountryDetailComponent } from './country-detail.component';
import { RegionDetailComponent } from './region-detail.component';
import { AppComponent } from './app.component';
// Route Configuration
export const routes: RouterConfig = [
{ path: '-/angular/country', component: CountryDetailComponent },
{ path: '-/angular/country/:countryId', component: CountryDetailComponent },
{ path: '-/angular/country/:countryId/:regionId', component: RegionDetailComponent },
{ path: '', redirectTo: '-/angular/country', pathMatch: 'full' },
{ path: '**', redirectTo: '' },
];
// Export routes
export const APP_ROUTER_PROVIDERS = [
provideRouter(routes, {enableTracing: true})
];
dependencies {
provided group: "com.liferay", name: "com.liferay.gradle.plugins", version: "latest.release"
provided group: "com.liferay", name: "com.liferay.portal.upgrade", version: "2.0.0"
provided group: "com.liferay.portal", name: "com.liferay.portal.impl", version: "2.0.0"
provided group: "com.liferay.portal", name: "com.liferay.portal.kernel", version: "2.4.0"
provided group: "javax.portlet", name: "portlet-api", version: "2.0"
provided group: "javax.servlet", name: "javax.servlet-api", version: "3.0.1"
provided group: "org.osgi", name: "org.osgi.service.component.annotations", version: "1.3.0"
}
apply plugin: "com.liferay.gulp"
apply plugin: "com.liferay.cache"
import com.liferay.gradle.plugins.gulp.ExecuteGulpTask
task compileTypeScript(type: ExecuteGulpTask)
compileTypeScript {
dependsOn npmInstall
gulpCommand = 'default'
}
classes {
dependsOn compileTypeScript
}
import { Component, Inject, Input, OnInit, SimpleChange } from '../node_modules/@angular/core';
import { Country } from './country';
import { LocationService } from './location.service';
import { RegionDetailComponent } from './region-detail.component';
import { ActivatedRoute, Router, ROUTER_DIRECTIVES } from '../node_modules/@angular/router';
@Component({
selector: 'location-content',
templateUrl: '/o/angular-portlet/templates/country-detail.component.html',
directives: [ROUTER_DIRECTIVES, RegionDetailComponent]
})
export class CountryDetailComponent implements OnInit {
country: Country;
sub: any;
constructor(
@Inject(Router)private router: Router,
@Inject(ActivatedRoute)private route: ActivatedRoute,
@Inject(LocationService) private locationService: LocationService) { }
ngOnInit() {
this.locationService.getCountries().then(() => {
this.sub = this.route.params.subscribe(params => {
let countryId = +params['countryId'];
if (countryId) {
this.country = this.locationService.getCountry(countryId);
}
});
});
}
onChange(regionId:number) {
this.router.navigate(['-/angular/country', this.country.countryId, regionId]);
}
}
import { Region } from './region';
export class Country {
countryId: number;
name: string;
a3: string;
regions: Region[];
constructor(config:any) {
this.countryId = config.countryId;
this.name = config.name;
this.a3 = config.a3;
}
}
var gulp = require('gulp');
var ts = require('gulp-typescript');
gulp.task('default', function () {
return new Promise(
function(resolve, reject) {
gulp.src([
'node_modules/core-js/**/*',
'node_modules/zone.js/**/*',
'node_modules/reflect-metadata/**/*',
'node_modules/systemjs/**/*',
'node_modules/@angular/**/*',
'node_modules/angular2-in-memory-web-api/**/*',
'node_modules/rxjs/**/*'
],
{
base: './'
})
.pipe(gulp.dest('src/main/resources/META-INF/resources/js'))
.on('end', resolve);
}).then(
function() {
gulp.src(['src/main/resources/**/*.ts','!**/node_modules/**'])
.pipe(
ts(
{
noImplicitAny: true,
experimentalDecorators: true,
module: 'amd',
moduleResolution: 'node'
}
)
).pipe(gulp.dest('classes'));
}
);
});
import { Injectable, Inject } from '@angular/core';
import { Country } from './country';
import { Region } from './region';
@Injectable()
export class LocationService {
cache:Country[];
constructor(@Inject('Liferay') private Liferay: any) { }
getCountries() {
var instance = this;
return new Promise<Country[]>((resolve) => {
if (!instance.cache) {
this.Liferay.Service(
'/country/get-countries',
{
active: true
},
function(response:any) {
instance.cache = response;
resolve(instance.cache);
}
);
}
else {
resolve(instance.cache);
}
});
}
getRegions(countryId:number) {
var instance = this;
var country = this.getCountry(countryId);
return new Promise<Region[]>((resolve) => {
if (!country.regions) {
this.Liferay.Service(
'/region/get-regions',
{
active: true,
countryId: countryId
},
function(response:any) {
country.regions = response;
resolve(country.regions);
}
);
}
else {
resolve(country.regions);
}
});
}
getCountry(countryId:number):Country {
if (!this.cache) {
return null;
}
for (var i = 0; i < this.cache.length; i++) {
var c = this.cache[i];
if (c.countryId == countryId) {
return c;
}
}
}
getRegion(countryId:number, regionId:number) {
var country = this.getCountry(countryId);
if (!country.regions) {
return null;
}
for (var i = 0; i < country.regions.length; i++) {
var r = country.regions[i];
if (r.regionId == regionId) {
return r;
}
}
}
}
///<reference path="../../../../../../../node_modules/typescript/lib/lib.es6.d.ts"/>
import { bootstrap } from '../node_modules/@angular/platform-browser-dynamic';
import { APP_ROUTER_PROVIDERS } from './app.routes';
import { AppComponent } from './app.component';
import { provide } from '@angular/core';
export function main(Liferay:any, A:any, baseRenderUrl:String) {
bootstrap(AppComponent, [
provide('Liferay', {useValue: Liferay}),
provide('baseRenderUrl', {useValue: baseRenderUrl}),
APP_ROUTER_PROVIDERS
]);
}
{
"name": "hello-angular",
"version": "1.0.0",
"scripts": {
"start": "tsc && concurrently \"npm run tsc:w\" \"npm run lite\" ",
"lite": "lite-server",
"postinstall": "typings install",
"tsc": "tsc",
"tsc:w": "tsc -w",
"typings": "typings"
},
"license": "ISC",
"dependencies": {
"@angular/common": "2.0.0-rc.4",
"@angular/compiler": "2.0.0-rc.4",
"@angular/core": "2.0.0-rc.4",
"@angular/forms": "0.2.0",
"@angular/http": "2.0.0-rc.4",
"@angular/platform-browser": "2.0.0-rc.4",
"@angular/platform-browser-dynamic": "2.0.0-rc.4",
"@angular/router": "3.0.0-beta.2",
"@angular/router-deprecated": "2.0.0-rc.2",
"@angular/upgrade": "2.0.0-rc.4",
"core-js": "^2.4.0",
"reflect-metadata": "^0.1.3",
"rxjs": "5.0.0-beta.6",
"zone.js": "^0.6.12",
"angular2-in-memory-web-api": "0.0.14",
"bootstrap": "^3.3.6"
},
"devDependencies": {
"concurrently": "^2.0.0",
"lite-server": "^2.2.0",
"gulp": "^3.9.0",
"gulp-typescript": "^2.13.6",
"typings":"^1.0.4"
}
}
<?xml version="1.0"?>
<!DOCTYPE routes PUBLIC "-//Liferay//DTD Friendly URL Routes 7.0.0//EN" "http://www.liferay.com/dtd/liferay-friendly-url-routes_7_0_0.dtd">
<routes>
<route>
<pattern>/country/{countryId}</pattern>
<implicit-parameter name="mvcPath">/view.jsp</implicit-parameter>
</route>
</routes>
(function(global) {
var basePath = '/o/hello-angular/js';
// map tells the System loader where to look for things
var map = {
'app': basePath + '/app', // 'dist',
'@angular': basePath + '/node_modules/@angular',
'angular2-in-memory-web-api': basePath + '/node_modules/angular2-in-memory-web-api',
'rxjs': basePath + '/node_modules/rxjs'
};
// packages tells the System loader how to load when no filename and/or no extension
var packages = {
'app': { main: 'main.js', defaultExtension: 'js' },
'rxjs': { defaultExtension: 'js' },
'angular2-in-memory-web-api': { main: 'index.js', defaultExtension: 'js' },
};
var ngPackageNames = [
'common',
'compiler',
'core',
'forms',
'http',
'platform-browser',
'platform-browser-dynamic',
'router',
'router-deprecated',
'upgrade',
];
// Individual files (~300 requests):
function packIndex(pkgName) {
packages['@angular/'+pkgName] = { main: 'index.js', defaultExtension: 'js' };
}
// Bundled (~40 requests):
function packUmd(pkgName) {
packages['@angular/'+pkgName] = { main: '/bundles/' + pkgName + '.umd.js', defaultExtension: 'js' };
}
// Most environments should use UMD; some (Karma) need the individual index files
var setPackageConfig = System.packageWithIndex ? packIndex : packUmd;
// Add package entries for angular packages
ngPackageNames.forEach(setPackageConfig);
var config = {
map: map,
packages: packages
};
System.config(config);
})(this);
@planetsizebrain
Copy link

I've been trying to use this information to create an Angular2 Hello World portlet, but while the JAR bundle deploys the portlet doesn't work and the console shows: (SystemJS) Raw is not defined(…)(anonymous function) @ angular:12

Any idea what could cause this?

@kutec
Copy link

kutec commented Feb 4, 2017

Did you try to drag and drop angular-portlet on Liferay frontend then?

@yuchi
Copy link

yuchi commented Mar 1, 2017

Copy link

ghost commented Mar 4, 2017

Here is our attempt to use Angular 2 in Liferay 7 - https://github.com/devsoftcz/liferay-angular2-seed

@planetsizebrain
Copy link

planetsizebrain commented Apr 5, 2017

@kutec yup, I got the message after adding the portlet to the page and doing a reload.
@yuchi I'm trying to use the loader, but am having mixed success. I was however able to load @devsoftcz Angular2 example portlet. The problem with that one is that it isn't instanceable and when I try to make it instanceable it start complaining about 'zone already loaded'.

@harmeet090
Copy link

harmeet090 commented Apr 13, 2017

@mjbradford89 i followed your configuration steps to create angular portlet but i am getting error in gulp file when i am build the portlet

error TS2304: Cannot find name 'Promise'.

Any idea how can i solve this issue...?

@planetsizebrain @yuchi

@cvraghu
Copy link

cvraghu commented Apr 14, 2017

I tried to implement this in Liferay 7. While the basic setup works, i'm unable to consume any json service. I wonder whether it is because of numerous 404s expecting node_modules to be under /web/guest. Please see example. http://localhost:8080/web/guest/node_modules/zone.js/dist/zone.js 404

Also http://localhost:8080/o/rc-choice-web/js/system.config.js 404 (Not Found). This is actually under /meta-inf/resources/js.

I've copied node_modules under portlet root. I had issues compiling otherwise.

Also how do we consume liferay json service? How can we pass credentials with httpget?

Could any one please help me out?

@planetsizebrain
Copy link

@v-rachen: I was able to get this to work, https://github.com/severinrohner/angular-poc-portlet. If you take care to make the selectors, component names, etc.. unique it even works with multiple portlets on a page (just not instanceable). It is even possible to put the node_modules stuff in a separate module and have the portlets load it from that module.

Copy link

ghost commented May 10, 2017

@planetsizebrain Unfortunately, making it instanceable is problematic as Angular doesn't support multiple running instances (it breaks the zone.js framework)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment