Monday, July 17, 2017

SharePoint and Angular2, part 1

Copyright from: blog.kennethhansen.info

the point

The company I work for uses a nifty app called SocialChorus to push news out to all our employees. Marketing wanted to have our intranet homepage be on brand and give out the news.
Here's the final product (blurred for corporate reasons):
Home Screen
It scrolls infinitely, runs on REST, and is a customization that can be done at the collection level (Visual Studio not required, although it is nice to have).

pre-requisite steps:

Turn on the publishing feature.
There needs to be a custom page content type created that is based on Html Page Layout. At the minimum add a single line of text column for the "Site URL" and "List Name". I also added the existing "Page Content". This content type will be applied to whatever page you create from the layout.
The news items are based on 1 list for the news articles and 1 list for the images. I set this up this way because we often use the same image for news about the same topic.
The news articles list has a content type based on Articles. There are 3 additional fields.
- "Content Channels" is a choice field and you can put as many channels as you like in here, I allow multiple selections. This field populates the selector on the left and filters the news items. - "News Body" is a multiple lines of text field, that allows full html. - "News Image" is a publishing image field.
The news images are stored in a Image, Video and Audio assets library.
If you need a walk through on the content type stuff look through this post Drag and Drop Selector.

masterpage FUN

First create a folder in your site collection masterpage library named "custom". I'm not the boss of you, so you can name it something else if you prefer, just fix it where you need to.
Next a folder called custom-masterpage. Now create a new HTML MasterPage named custom-master-ts.html.
I like to work in a totally local directory, because NPM pulls in way more files than you need in production. Then I pick and choose what goes into SharePoint.
Soooo, make a local folder named custom. Now inside there add a package.json file.
{
  "name": "sharepoint-angular2",
  "version": "1.0.0",
  "license": "ISC",
  "dependencies": {
    "angular2": "^2.0.0-beta.0",
    "systemjs": "^0.19.8",
    "es6-promise": "^3.0.2",
    "es6-shim": "^0.33.3",
    "reflect-metadata": "^0.1.2",
    "rxjs": "^5.0.0-beta.0",
    "zone.js": "^0.5.10",
    "jquery": "~2.1.4",
    "bootstrap": "~3.3.6"
  },
  "devDependencies": {
    "typescript": "^1.7.5",
    "jasmine-core": "^2.4.1"
  }
}
Now run npm install to get all the goodies you need.
In your local custom folder, you should now have a "node_modules" folder with all these files. You also need to have typescript 1.8.0+ globally installed for some of the things we'll do. This is beta at the moment so the command is npm install -g typescript@next.
Back in SharePoint, in the "custom" folder, add a "node_modules" folder and copy the files and folders below.
node_modules  
+-- angular2
|||+-- bundles
|||||||-- angular2.dev.js
|||||||-- angular2.js
|||||||-- angular2.min.js
|||||||-- angular2-polyfills.js
|||||||-- angular2-polyfills.min.js
|||||||-- http.dev.js
|||||||-- http.js
|||||||-- http.min.js
|||||||-- router.dev.js
|||||||-- router.js
|||||||-- router.min.js
+-- bootstrap
|||+-- dist
||||||+-- js
||||||||||-- bootstrap.min.js
||||||+-- css
||||||||||-- bootstrap.min.css
+-- es6-shim
||||-- es6-shim.js
||||-- es6-shim.map
||||-- es6-shim.min.js
+-- jquery
|||+-- dist
|||||||-- jquery.js
|||||||-- jquery.min.map
|||||||-- jquery.min.js
+-- reflect-metadata
||||-- Reflect.js
||||-- Reflect.js.map
||||-- Reflect.ts
+-- rxjs
|||+-- bundles
|||||||-- Rx.js
|||||||-- Rx.min.js.map
|||||||-- Rx.min.js
+-- systemjs
|||+-- dist
|||||||-- system.js
|||||||-- system.js.map
|||||||-- system.src.js
|||||||-- system-polyfills.js
|||||||-- system-polyfills.js.map
|||||||-- system-polyfills.src.js
+-- zone.js
|||+-- dist
|||||||-- zone.js
|||||||-- zone.min.js
You can copy more, but this is what you need for Angular to work. You can set up most editors to push to the DavWWW folder, or map a local drive to the masterpage folder, whatever you like.
As I said, I like to do everything in a local folder and then push to SharePoint. You can do the same. If you do, you only need the HTML version of the masterpage.
When you create a masterpage it will have a link in the comment at the top that will direct you to the Snippet gallery for this page. It's a really simple way to add controls to your page.
Look for the SharePoint:ScriptLink tags:
  
  
  
  
  
  
Underneath that add a link to the stylesheets and Scriptlink tags for our scripts:
<link href="../node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" type="text/css" ms-design-css-conversion="no" />  
<link href="app/css/custom-master.css" rel="stylesheet" type="text/css" ms-design-css-conversion="no" />  
  
  
  
  
  
  
  
  
  
<script type="text/javascript" src="../node_modules/jquery/dist/jquery.min.js"></script>  
<script type="text/javascript" src="../node_modules/bootstrap/dist/js/bootstrap.min.js"></script>  
OK, so maybe you're wondering why jquery and bootstrap are regular script tags and the rest are SharePoint:Scriptlink tags. This is because the Angular files (the polyfill in particular) clash with SharePoint's core.js and destroys the ability to edit pages in edit mode, so we need to be certain that they are not loaded until after core.js, which is well after the UI is loaded.
The ScriptLink will register the file to be ready On Demand. If these files were stored in the "/_layouts/15/" folder on the server we could use the RegisterSod function to the same effect.
So now it's time to set up the dependencies to make sure the Angular files wait for core.js and then load in the right order.
<script>  
  RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/es6-shim/es6-shim.min.js', 'core.js');
  RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/systemjs/dist/system-polyfills.js', '~sitecollection/_catalogs/masterpage/custom/node_modules/es6-shim/es6-shim.min.js');
  RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/angular2-polyfills.min.js', '~sitecollection/_catalogs/masterpage/custom/node_modules/systemjs/dist/system-polyfills.js');
  RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/node_modules/systemjs/dist/system.js', '~sitecollection/_catalogs/masterpage/custom/node_modules/angular2/bundles/angular2-polyfills.min.js');
  RegisterSodDep('~sitecollection/_catalogs/masterpage/custom/custom-masterpage/app/master.system.config.js', '~sitecollection/_catalogs/masterpage/custom/node_modules/systemjs/dist/system.js');
  EnsureScriptFunc('~sitecollection/_catalogs/masterpage/custom/custom-masterpage/app/master.system.config.js', null, function () { });
</script>  
Cool. This uses the SharePoint function EnsureScriptFunc(key, func, callback) to call the file that contains the SystemJS configuration, which depends on system.js, which depends on angular2-polyfills.min.js, which depends on system-polyfills.js, which depends on es6-shim.min.js, which depends on core.js. Whew!
One problem, there isn't a master.system.config.js file yet. Create an app folder in the custom folder and add a file by that name. Here's the configuration:
System.config({  
  baseURL: _spPageContextInfo.siteAbsoluteUrl + '/_catalogs/masterpage/custom',
  packages: {
    'custom-masterpage/app': {
      format: 'register',
      defaultExtension: 'js'
    }
  },
  map: {
    'jquery': 'node_modules/jquery/dist/jquery.min.js'
  }
});

System.appArray = ['custom-masterpage/app/app'];

SP.SOD.notifyScriptLoadedAndExecuteWaitingJobs('~sitecollection/_catalogs/masterpage/custom/custom-masterpage/app/master.system.config.js');  
This sets the app base url to the custom folder in the masterpage gallery of the current site collection, sets up a package for the masterpage app and maps jQuery for the few times we'll need it explicitly.
The last line is not actually needed, but you could use that functionality to hook into SharePoint's ExecuteOrDelayUntilScriptLoaded function, but that's another post.
Inside the app folder create a folder for css and images. In the image folder, place a image to be used as a logo. In the css folder make a file called custom-master.css, and add some style:
#ms-designer-ribbon,
#ms-designer-ribbon *,
[class*=ms-] {
    -webkit-box-sizing: content-box;
       -moz-box-sizing: content-box;
            box-sizing: content-box;
}

body { position: absolute; }

/* SharePoint Left Control Menu - add padding to account for logo */
.ms-cui-tts, .ms-cui-tts-scale-1, .ms-cui-tts-scale-2 {
    padding: 0 0 0 7.5% !important;
}

/*************************************************************
 * Nav-Bar styles for logo text and first level
 * Flyout styles are handled by nav-flyout module
 *************************************************************/

#top-bar {
    overflow: visible;
}

.navbar { margin-bottom: 0 }
.navbar-brand { padding: 0 }

.nav-container {
    height: 100%;
    border-bottom: thin #538C3F solid;
}

#team-logo {
    position: absolute;
    float: left;
    margin-left: 2.5%;
    margin-top: -1.70%;
    height: 105px;
    z-index: 150; 
}

.nav-list {
    position: relative;
    padding-top: 1.5em;
    padding-left: 6em;
}

.nav-search {
    float: right;
    margin-top: 1em;
}

.ms-srch-sb>input {
    height: 1.5em;
    font-size: 1.1em;
    width: 150px;
}

.ms-srch-sb-navLink, .ms-srch-sb-searchLink {
    vertical-align: middle;
}

.nav-list ul {
    list-style: none;
}

.nav-list ul.dynamic {
    background-color: #D3D3D3;
    box-shadow: none;
    padding: 0 0 2px 0;

}
.nav-list ul.dynamic li.dynamic a {
    margin: 0 6px;
    color: #000;
}

.nav-list ul.dynamic li.dynamic:hover {
    background-color: #C2DA9C;
}

.nav-list ul.dynamic ul.dynamic {
    background-color: #EFEFEF;
    white-space: nowrap;
}

.nav-list li.dynamic {
    padding: 6px 0;
    border-top: thin #fff solid;
}

#s4-bodyContainer { margin-top: 1.8em }
The first 3 styles are super important. They make it so the Microsoft controls in the ribbon and other places don't break because of Bootstrap styles. The rest of this is basically styling the top navigation menu that we are about to build.
At the top of the masterpage file, there should be a large commented out section that has a link to the Snippet Gallery, copy the code for the Top Navigation from that site. Now find the body tag in the custom-master-ts file. The top navigation is going to be placed in the designer ribbon to fix it to the top of the page.
Inside
<div id="ms-designer-ribbon">  
find these tags
  
  <div class="DefaultContentBlock" style="background:rgb(0, 114, 198); color:white; width:100%; padding:8px; height:64px; overflow:hidden;">In true previews of your site, the SharePoint ribbon will be here.</div>
Right after that add the following to create a collapsing menu using Bootstrap:
<nav id="top-bar" class="navbar navbar-default s4-notdlg">  
  <div class="container-fluid nav-container">
    
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse" aria-expanded="false">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="#">
        <img id="team-logo" alt="Team Daugherty!" src="app/images/Koala.jpg" />
      </a>
    </div>
    
  </div>
</nav>  
Paste the Top Navigation snippet where the comment is. There are a couple customizations to make here. The first div needs some class and an id.
<div class="collapse navbar-collapse" id="links" data-name="TopNavigationNoFlyoutWithStartNode">  
After the startNavigation anchor tag find the SharePoint:AspMenu tag and add CssClass="nav-list" and on the next div add class="nav-search". Here's the whole thing:
<div class="collapse navbar-collapse" id="links" data-name="TopNavigationNoFlyoutWithStartNode">  
  
  
  
    
    
      <span style="display:none"><table cellpadding="4" cellspacing="0" style="font:messagebox;color:buttontext;background-color:buttonface;border: solid 1px;border-top-color:buttonhighlight;border-left-color:buttonhighlight;border-bottom-color:buttonshadow;border-right-color:buttonshadow"><tr><td nowrap="nowrap"><span style="font-weight:bold">PortalSiteMapDataSource</span> - topSiteMap</td></tr><tr><td></td></tr></table></span>
      
        
        
      
    
    <a name="startNavigation">
    </a>
    
      
        
      
    
    <div class="nav-search" data-name="SearchBox">
      
      
      
      
      
      
    </div>
    
  
  
</div>  
Three files to go...
First we'll look at tsconfig.json. This goes in the root folder (custom). Here it is:
{
  "compilerOptions": {
    "target": "ES5",
    "module": "system",
    "moduleResolution": "node",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "removeComments": true,
    "noImplicitAny": false,
    "sourceRoot": "//_catalogs/masterpage/custom/"
  },
  "exclude": [
    "node_modules",
    "Scripts"
  ]
}
This tells Typescript to compile down to ES5 which works with IE9+ and all other browsers. the modules will be formatted in SystemJS style, a source map will be created that directs to the ts file at the source root and it skips the node_modules folder.
Now in the app folder, create two typescript files, app.ts and navFly.ts.
Here is app.ts:
/// 
/// 

import {FlyDown} from './navFly';

let init = () => {  
  //let elements: HTMLCollection = document.querySelectorAll('li.dynamic.dynamic-children');
  let fixFly: FlyDown = new FlyDown('li.dynamic.dynamic-children');
  fixFly.adjustFly();
}

SP.SOD.executeFunc('sp.js', 'SP.ClientContext', init);  
You will need to get some definitely typed files. Install tsd with npm install tsd -g.
Now 2 commands to get the definition files for sharepoint and jquery.
tsd install sharepoint --save  
tsd install jquery --save  
Here's the code for navFly.ts:
export class FlyDown {  
  flyingNavs: HTMLCollection;
  flyingArray: Array<HTMLElement>;
  totalHeight: number;
  listItemFilter: boolean;

  constructor(flyingNavsSelector: string) {
    this.flyingNavs = <HTMLCollection>document.querySelectorAll(flyingNavsSelector);
    this.flyingArray = Array.prototype.slice.call(this.flyingNavs);
  }

  howHigh(el: HTMLElement, filter: Function): number {
    let sibsHeight: number = 0, element = el;
    while (element.previousSibling && element.nodeName == element.previousSibling.nodeName) {
      if (!filter || filter(element)) {
        element = <HTMLElement>element.previousSibling;
        sibsHeight += element.offsetHeight;
      }
    }
    return sibsHeight + 1;
  }

  isListItem(el: HTMLElement): boolean {
    let nodeName = el.nodeName.toUpperCase();
    if (nodeName == 'LI')
      return true;
    return false;
  }

  onmouseenter(el: HTMLElement) {
    let element: HTMLElement = el;
    element.addEventListener('mouseover', (event) => {
      let childUl: HTMLElement = element.getElementsByTagName('ul')[0],
          parentUl: HTMLElement = <HTMLElement>element.parentNode,
          parentHeight: number = parentUl.offsetHeight,
          childHeight: number = childUl.offsetHeight,
          prevLIHeights: string = '-' + this.howHigh(element, this.isListItem) + 'px';

      childUl.style.top = prevLIHeights;
      if (parentHeight < childHeight - 2) {
        childUl.style.height = childHeight + 'px';
        parentUl.style.height = childHeight + 'px';
      }
      else if (parentHeight > childHeight + 2) {
        parentUl.style.height = parentHeight + 'px';
        childUl.style.height = parentHeight + 'px';
      }
    })
  }

  onmouseleave(el: HTMLElement) {
    let element: HTMLElement = el;
    element.addEventListener('mouseleave', (event) => {
      let parentUl: HTMLElement = <HTMLElement>element.parentNode;
      parentUl.style.height = 'auto';
    })
  }

  adjustFly() {
    for (let listEl of this.flyingArray) {
      this.onmouseenter(listEl);
      this.onmouseleave(listEl);
    }
  }
}
Fancy. Look at all that pretty ES6 syntax with types and everything. When the mouse hovers over a dynamic menu item that has sub items it will compare the heights of the 2 ULs and match their height and top placement.
Now go to the console in the custom folder and tsc.
Push those files to your site collection, you may have to give them a name and check them in in the library. This is all well and good, but it won't work without a layout, and there's no angular yet... Coming right up.

No comments: