Monday, July 17, 2017

SharePoint and Angular2, part 2

Copyright from: blog.kennethhansen.info

In Part 1 I showed how to set up the SharePoint environment, files and a Masterpage.
The source files are located here Git
Now for the Layout page, where the Angular magic happens.
In SharePoint in the custom folder create a new folder called news-items. In that folder create an HTML Layout and associate the Layout Content type that was created in part 1, name it news-items-ts.
There also needs to be an "app" folder created in the news-items folder.
Before we get to the layout, there are a couple of shared files that are needed. In the custom folder, create another folder called shared-modules.
There are 2 files in here "request.service.ts" and "escape.pipe.ts". These are helper files that could be used by any angular layout you might create.
escape.pipe.ts
import {Pipe, PipeTransform} from 'angular2/core';

@Pipe({ name: 'escape' })
export class EscapePipe implements PipeTransform {  
  transform(value: string, args:string[]): any {
    return (!value) ? '' : value.replace(/[^A-Z0-9]+/ig, "_")
 }
}
This replaces all sorts of symbols and spaces with an underscore.
request.service.ts
import {Injectable} from 'angular2/core';  
import {Http, Headers, RequestOptions, Request, RequestMethod, Response, URLSearchParams} from 'angular2/http';  
import 'rxjs/add/operator/map';

export class RequestURL {  
  fullURL: string;
  siteUrl = _spPageContextInfo.siteAbsoluteUrl;
  webUrl = _spPageContextInfo.webAbsoluteUrl;
  getLibraryItemsByUrlStart = '/_api/web/GetFolderByServerRelativeUrl(\'';
  getLibraryItemsByUrlEnd = '\')/files';
  getListItemsByNameStart = '/_api/lists/getbytitle(\'';
  getListItemsByNameEnd = '\')/';
  constructor(
    spUrlListParam: string,    
    isCollectionRequest: boolean,
    isLibraryRequest: boolean,
    isPost: boolean
  ) {
    let baseURL = isCollectionRequest ? this.siteUrl : this.webUrl,
      apiStart = this.getListItemsByNameStart,
      apiEnd = isPost ? this.getListItemsByNameEnd + 'getitems' : this.getListItemsByNameEnd + 'items';
    if (isLibraryRequest) {
      apiStart = this.getLibraryItemsByUrlStart;
      apiEnd = this.getLibraryItemsByUrlEnd;
    }

    this.fullURL = [baseURL, apiStart, spUrlListParam, apiEnd].join('')
  }
}

export interface SearchOptions {  
  select?: string;
  orderby?: string;
  expand?: string;
  filter?: string;
  top?: string;
}

export class SearchParams {  
  searchParams: URLSearchParams;
  constructor(searchOptions: SearchOptions = {
    select: null,
    orderby: null,
    expand: null,
    filter: null,
    top: null
  }) {
    let search = new URLSearchParams();
    if (searchOptions.select)
      search.set('$select', searchOptions.select)
    if (searchOptions.orderby)
      search.set('$orderby', searchOptions.orderby)
    if (searchOptions.expand)
      search.set('$expand', searchOptions.expand)
    if (searchOptions.filter)
      search.set('$filter', searchOptions.filter)
    if (searchOptions.top)
      search.set('$top', searchOptions.top)
    this.searchParams = search;
  }
}

@Injectable()
export class RequestService {

  constructor(private _http: Http) { }

  makeRequest(
    reqURL: string,
    search: URLSearchParams = null,
    body: string = null,
    method: RequestMethod = RequestMethod.Get,
    headers: Headers = new Headers({ accept: 'application/json; odata=verbose', 'content-type': 'application/json; odata=verbose' })
  ) {
    let reqOptions = new RequestOptions({
      url: reqURL,
      method: method,
      headers: headers,
      body: body,
      search: search
    });
    let req = new Request(reqOptions)
    return this._http.request(req).map((res) => { return res.json().d });
  }

  getXReqDigest(url: string) {
    return this.makeRequest(url, null, null, RequestMethod.Post).map((res) => { return res });
  }
}
There's a lot going on in here. There's a request url builder that uses boolean values to choose the appropriate base url and REST endpoints, a search params builder to set query params, the make request injectable that sends the HTTP request and returns the response, there is also a getXReqDigest in case this is a cross collection request (this is a little faster than the request executor that SharePoint recommends).
Now for the customization of the Layout page.
In the Additional Page Head Placeholder right before the close tag for that Placeholder
  
...Some other tags
Put code here  
  
Add this right before the closing tag above.
<link href="app/css/news-items.css" rel="stylesheet" type="text/css" ms-design-css-conversion="no" />  
  
<script>  
    RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/news-items/app/news.system.config.js', '~sitecollection/_catalogs/masterpage/custom/custom-masterpage/app/master.system.config.js');
    RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/rxjs/bundles/Rx.min.js', '~sitecollection/_catalogs/masterpage/custom/news-items/app/news.system.config.js');
    RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/angular2.min.js', '~sitecollection/_catalogs/masterpage/custom/node_modules/rxjs/bundles/Rx.min.js');
    RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/http.min.js', '~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/angular2.min.js');
    RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/router.min.js', '~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/http.min.js');
    EnsureScriptFunc('~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/router.min.js', null, function () {
      for (var i = 0, l = System.appArray.length; i < l; i++) {
        var file = System.appArray[i];
        System.import(file);
      }
    });

    window.twttr = (function (d, s, id) {
      var js, fjs = d.getElementsByTagName(s)[0],
        t = window.twttr || {};
      if (d.getElementById(id)) return t;
      js = d.createElement(s);
      js.id = id;
      js.src = "https://platform.twitter.com/widgets.js";
      fjs.parentNode.insertBefore(js, fjs);

      t._e = [];
      t.ready = function (f) {
        t._e.push(f);
      };

      return t;
    }(document, "script", "twitter-wjs"));
  </script>
It turns out that when using angular with SharePoint and placing the files in the head, it is best (it only seems to work) to load angular after SystemJS is configured. So here we register the layout page configuration file and set up dependencies. The EnsureScriptFunc loops through the appArray that was added to the System object once router.min.js is loaded, which depends on http.min.js which depends on angular2.min.js, which depends on Rx.min.js which depends on news.system.config.js which depends on master.system.config.js. Again, that's a mouthful.
Put this after the title snippet in the body of the page.
<div class="container-fluid">  
    <div class="row">
      <div class="col-md-9" id="news-zone">
        
        <div id="edit-container" data-name="EditModePanelShowInEdit">
          
          
          
          
          <div ng-form="listSelector">

            <div class="col-md-6">
              
            </div>
            <div class="col-md-6">
              
            </div>
            <edit-list-view></edit-list-view>
          </div>
          
          
          
        </div>
        
        
        <div data-name="EditModePanelShowInRead">
          
          
          
          
          <display-news></display-news>
          
          
          
        </div>
        
      </div>
      <div class="col-md-3" id="news-sidebar">
        <div id="static-page-content">

          <div class="highlight-content-area">
            
          </div>
          <div class="MY-tweets">
            <a class="twitter-timeline" href="https://twitter.com/USERNAME" data-widget-id="XXXXXXXXXXXXXXXX" data-chrome="nofooter" height="300">Tweets by @USERNAME</a>            
          </div>
        </div>
      </div>
    </div>
  </div>
There are 3 snippets you need to get from the snippet generator, Site URL, List Name and Page Content. These go where the comments show them, and the twitter timeline needs your own info...
All of the angular magic will happen in the  tag.
Now the system config for the layout app. In the app folder create news.system.config.js
System.config({  
  packages: {
    'news-items/app': {
      format: 'register',
      defaultExtension: 'js'
    },
  },
  map: {
    'escape.pipe': 'shared-modules/escape.pipe.js',
    'request.service': 'shared-modules/request.service.js'
  }
});
System.appArray.push('news-items/app/appStart');

SP.SOD.notifyScriptLoadedAndExecuteWaitingJobs('~sitecollection/_catalogs/masterpage/custom/news-items/app/news.system.config.js');  
You can see here that SystemJS is pushing the appStart file into the appArray that was added to the System object.
appStart.ts goes in the app folder
import {AppComponent} from './app.component';  
import {bootstrap} from 'angular2/platform/browser';  
import {provide, enableProdMode} from 'angular2/core';  
import {ROUTER_PROVIDERS, RouteConfig, Location, LocationStrategy, HashLocationStrategy} from 'angular2/router';  
import {HTTP_PROVIDERS} from 'angular2/http';  
import {RequestService} from 'request.service';

enableProdMode();

bootstrap(AppComponent, [  
  RequestService,
  HTTP_PROVIDERS,
  ROUTER_PROVIDERS,
  provide(LocationStrategy, { useClass: HashLocationStrategy })
]);
There is where the app boots up. It has the request service that was written earlier, and it uses the HashLocationStrategy, because SharePoint wasn't playing nice with the HTML5 mode that is the default.
Now app.component.ts:
/// 

import {Component, Injectable, OnInit, NgZone, Directive, ElementRef, Renderer} from 'angular2/core';  
import {NgFor} from 'angular2/common';  
import {RouteConfig, ROUTER_DIRECTIVES} from 'angular2/router';  
import {NewsItems, NewsService, PageProps} from './news.service';

declare var jQuery: JQueryStatic;

@Component({
  selector: 'display-news',
  directives: [NgFor, ROUTER_DIRECTIVES],
  providers: [NewsService],
  bindings: [NewsService],
  template: `
    <div class="col-md-4" id="channel-filter">
      <div class="channels">
        <div *ngFor="#channel of channels" class="channel-item" [class.is-selected]="channel == currentChannel"  (click)="getNews(channel)">{{ channel }}</div>
      </div>
    </div>
    <div class="col-md-8" id="news-item-results">
      <div *ngFor="#newsItem of newsResults; #idx = index; #last = last;" class="news-item-container" (click)="showDetail(newsItem)" [class.last-result]="last">
        <div class="news-item-image">
          <div [innerHTML]="newsItem.imageUrl"></div>
          <div class="news-item-image-overlay"></div>
          <h2>{{newsItem.title}}</h2>
        </div>
        <div class="news-item-headline">
          <p>{{newsItem.bodySummary}}</p>
        </div>
      </div>
      <last-result></last-result>
    </div>
    <div class="news-item-detail modal fade" role="dialog" aria-labelledby="newsModalLabel">
      <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">&times;</span></button>
          </div>
          <div class="modal-body">
            <div class="news-item-image">
              <div [innerHTML]="selectedItem.imageUrl"></div>
            </div>
            <div class="modal-news-content">
              <h1>{{selectedItem.title}}</h1>
              <div class="news-item-article" [innerHTML]="selectedItem.bodyText"></div>
            </div>
          </div>
          <div class="modal-footer">
            <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
          </div>
        </div>
      </div>
    </div>
  `
})

export class AppComponent implements OnInit {

  newsResults: Array<NewsItems>;
  channels: Array<string>;
  pageProperties: PageProps;
  currentChannel: string;
  showDetailView: boolean;
  selectedItem: NewsItems;
  flag: boolean;

  constructor(private _newsService: NewsService, private _ngZone: NgZone) {
    this.newsResults = [];
    this.currentChannel = null;
    this.showDetailView = false;
    this.selectedItem = {
      id: null,
      title: '',
      bodyText: '',
      bodySummary: '',
      contentChannels: [],
      fieldValueUri: '',
      authorName: '',
      authorEmail: '',
      imageUrl: null
    };
    this.flag = false;
    let self = this; //jQuery hack
    jQuery('#s4-workspace').scroll(function (evt) {
      self.isScrolling(self);
    });
  }

  getNews(channel?: string) {
    let selectedChannel = channel == 'Home' ? null : channel;
    this.currentChannel = channel == null ? 'Home' : channel;
    this._newsService.getNewsHeadlines(this.pageProperties, selectedChannel).subscribe((newsResults) => {
      let parser = new DOMParser();
      this.newsResults = [];
      this.pageProperties.nextRequest = newsResults.__next;
      this._ngZone.run(() => { console.log('Got the news!') });
      for (let item of newsResults.results) {
        let newsItem = {
          id: item.Id,
          title: item.Title,
          bodyText: item.News_x0020_Body,
          bodySummary: parser.parseFromString(item.News_x0020_Body_x0020_, "text/html").documentElement.textContent.substring(0, 220),
          contentChannels: item.Content_x0020_Channels.results,
          fieldValueUri: item.FieldValuesAsHtml.__deferred.uri,
          authorName: item.Author.Title,
          authorEmail: item.Author.Email,
          imageUrl: null
        }
        this.newsResults.push(newsItem);
        this._newsService.getNewsImage(newsItem.fieldValueUri).subscribe((result) => {
          newsItem.imageUrl = result.News_x005f_x0020_x005f_Image;
          this._ngZone.run(() => { console.log('Got the image!') });
        });
      }
    });
  }

  showDetail(newsItem: NewsItems) {
    this.showDetailView = true;
    jQuery('.news-item-detail').modal();
    this.selectedItem = newsItem;    
  }

  onScrollLast() {
    if (this.flag) return;
    this.flag = true;
    this._newsService.getNextResults(this.pageProperties.nextRequest).subscribe((newsResults) => {
      let parser = new DOMParser();
      this.pageProperties.nextRequest = newsResults.__next;
      this._ngZone.run(() => { console.log('Got the news!') });
      for (let item of newsResults.results) {
        let newsItem = {
          id: item.Id,
          title: item.Title,
          bodyText: item.News_x0020_Body,
          bodySummary: parser.parseFromString(item.News_x0020_Body_x0020_, "text/html").documentElement.textContent.substring(0, 220),
          contentChannels: item.Content_x0020_Channels.results,
          fieldValueUri: item.FieldValuesAsHtml.__deferred.uri,
          authorName: item.Author.Title,
          authorEmail: item.Author.Email,
          imageUrl: null
        }
        this.newsResults.push(newsItem);
        this._newsService.getNewsImage(newsItem.fieldValueUri).subscribe((result) => {
          newsItem.imageUrl = result.News_x005f_x0020_x005f_Image;
          this._ngZone.run(() => { console.log('Got the image!') });
        });
      }
      this.flag = false;
    });
  }

  isScrolling(self:any) {
    let winHeight = jQuery(window).height();
    let elTop = jQuery('last-result').offset().top;
    if (elTop < winHeight * 1.15) {
      if (self.pageProperties.nextRequest != null)
        self.onScrollLast()
    }
  }


  ngOnInit() {

    this._newsService.getPageProperties().subscribe((res) => {
      this.pageProperties = {
        id: res.Id,
        siteUrl: res.Site_x0020_URL,
        listName: res.List_x0020_Name,
        uri: res.__metadata.uri,
        spType: res.__metadata.type,
        eTag: res.__metadata.etag,
        nextRequest: null
      }
      this._ngZone.run(() => { console.log('Got the Page Props!') });

      this.getNews();

      this._newsService.getFields(this.pageProperties, 'DBS Content Channels', 'Choices').subscribe((newsFields) => {
        this.channels = newsFields[0].Choices.results;
        this._ngZone.run(() => { console.log('Got the channels!') });
      });
    });
  }
}
Wow! Yep all that for free. I will at some point in the future break this down, but I just need to get this posted right now, so you can look through it and see what you think.
NgZone is required for IE compatability, as of this writing. Without it, all sorts of strange things happen.
After looking through you will see it relies on news.service.ts:
/// 
import {Injectable, NgZone} from 'angular2/core';  
import {Headers, RequestMethod} from 'angular2/http';  
import {RequestService, RequestURL, SearchParams, SearchOptions} from 'request.service';  
import 'rxjs/add/operator/map';

export interface PageProps {  
  id: number;
  siteUrl: string;
  listName: string;
  uri: string;
  spType: string;
  eTag: string;
  nextRequest: string;
}

export interface NewsItems {  
  id: number;
  title: string;
  bodyText: string;
  bodySummary: string;
  contentChannels: Array<string>;
  fieldValueUri: string;
  authorName?: string;
  authorEmail?: string;
  imageUrl?: string;
}


@Injectable()
export class NewsService {

  constructor(private _requestService: RequestService, private _ngZone: NgZone) { }

  getPageProperties() {
    let pagePropUrl = `${_spPageContextInfo.webAbsoluteUrl}/_api/web/lists(guid'${_spPageContextInfo.pageListId.substring(1, _spPageContextInfo.pageListId.length - 1)}')/items(${_spPageContextInfo.pageItemId})`;
    return this._requestService.makeRequest(pagePropUrl).map((res) => { return res });
  }

  getFields(pageProps: PageProps, fieldName?: string, fieldProp?: string) {
    let listUrl = `${pageProps.siteUrl}/_api/web/lists/getbytitle('${pageProps.listName}')/fields`;
    let listSearch = new SearchParams({
      filter: fieldName != null ? `Title eq '${fieldName}'`: null,
      select: fieldProp
    }).searchParams;
    return this._requestService.makeRequest(listUrl, listSearch).map((res) => { return res.results });
  }

  getNewsHeadlines(pageProps: PageProps, channel?: string) {
    let newsUrl = `${pageProps.siteUrl}/_api/web/lists/getbytitle('${pageProps.listName}')/items`;
    let newsSearch = new SearchParams({
      top: '12',
      select: 'Id,Title,News_x0020_Body,Content_x0020_Channels,FieldValuesAsHtml,Author/EMail,Author/Title',
      orderby: 'Id desc',
      expand: 'Author',
      filter: channel != null ? `Content_x0020_Channels eq '${channel}'` : null
    }).searchParams;

    return this._requestService.makeRequest(newsUrl, newsSearch).map((res) => { return res });
  }

  getNextResults(nextUrl: string) {
    return this._requestService.makeRequest(nextUrl).map((res) => { return res });
  }

  getNewsImage(itemUrl: string) {
    let newsImageUrl = `${itemUrl}`;
    return this._requestService.makeRequest(newsImageUrl).map((res) => { return res });
  }

}
Again, lots of stuff, but I'm going to take my time breaking it down. I'll just leave it here for now.
A little bit of styling
/************************ News Items ****************************/

#s4-workspace { background-color: #f1f1f1 }

.channels {
  position: fixed;
  top: 160px;
  left: 3%;
  width: 19%;
  max-height: 70%;
  box-shadow: 0px 1px 3px 0px rgba(0,0,0,0.2);
  background-color: #fff;
  overflow-y: scroll;
}

.channels .channel-item {
  display: block;
  background-color: #fff;
  height: 58px;
  line-height: 26px;
  padding: 16px;
  padding-left: 32px;
  position: relative;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.channels .channel-item.is-selected {
  background-color: #E2E2E2;
  border-left: 4px solid #3498DB;
  font-weight: 700
}

.channels .channel-item:hover { 
  background-color: #e2e2e2;
  cursor: pointer;
}

.news-item-container {
  margin-bottom: 32px;
  position: relative;
  box-shadow: 0px 1px 3px 0px rgba(0,0,0,0.2);
  background-color: #fff;
}

.news-item-container:hover { cursor: pointer }

.news-item-image, .news-item-image > div > img {
  position: relative;
}

.news-item-image > div > img {
  width: 100%;
  height: 100%;
  background-color: #fff;
}

.news-item-image-overlay {
  position: absolute;
  top: 60%;
  right: 0;
  bottom: 0;
  left: 0;
  background: -moz-linear-gradient(top, transparent 0%, rgba(0,0,0,0.85) 100%);
  background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, transparent), color-stop(100%, rgba(0,0,0,0.85)));
  background: -webkit-linear-gradient(top, transparent 0%, rgba(0,0,0,0.85) 100%);
  background: -o-linear-gradient(top, transparent 0%, rgba(0,0,0,0.85) 100%);
  background: -ms-linear-gradient(top, transparent 0%, rgba(0,0,0,0.85) 100%);
  background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.85) 100%);
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#a6000000', GradientType=0);
}

.news-item-image > h2 {
  position: absolute;
  bottom: 1%;
  left: 1%;
  color: #fff;
}

.news-item-headline {
  padding: 1%;
  font-size: 1em;
}

.news-item-detail .modal-content { border-radius: 0 }

.news-item-detail .modal-content button.close { min-width: 0 }

.news-item-detail .modal-content .modal-body { padding: 0 }

.modal-news-content {
  padding-left: 36px;
  padding-right: 36px;
}

.modal-news-content h1, .news-item-article {
  padding-top: 15px;
  padding-bottom: 15px;
}

.modal-news-content h1 { text-align: center }

.news-item-detail .modal-footer { text-align: center }

/************************ End News Items ************************/
You should be good to go.

No comments: