This example demonstrates the powerful navigation and view management features Y.App
provides by creating an application to browse through the contributors of GitHub projects.
HTML
This application starts with a bare container which will be used to define the bounds of the app and provide a place for it to render its contents.
<div id="github-app"></div>
Templates
This app makes heavy use of Handlebars templates to create reusable chunks of HTML which will be used by our views render the different "pages" and sections of our app.
These templates, while being placed within the markup of the page, are invisible and not rendered by the browser. By putting the templates inside of a <script>
elements with type="text/x-handlebars-template"
, the browser will ignore its contents (and therefore not try to execute it as a script) because it doesn't recognize the text/x-handlebars-template
content-type.
You'll notice all of these <script>
elements contain id
attributes; this allows us to easily reference a template by-id and retrieve its contents as a string which can by compiled into a Handlebars template function.
This is generally a more maintainable way of embedding templates than storing them in JavaScript strings, but there's no requirement that you use this technique.
<!-- HomePage Template: Template HTML used by the `HomePageView` to render the app's home page. --> <script id="t-home" type="text/x-handlebars-template"> <h1>Contributors of GitHub Projects</h1> <p> <input id="github-app-username" type="text" value="{{login}}" placeholder="GitHub Username" /> <button class="yui3-button">Show Repos</button> </p> <p> <label for="github-app-username">(e.g. yui, davglass, rgrove)</label> </p> </script> <!-- User Template: Template HTML used by the `UserPageView` to render the header section of a user's page in the app; this displays info and stats about the GitHub user. --> <script id="t-user" type="text/x-handlebars-template"> <div class="yui3-g"> <p class="back yui3-u-1-2"> <a href="#/">« Choose Someone Different</a> </p> <p class="view-on-github yui3-u-1-2"> <a href="{{html_url}}">View on GitHub</a> </p> </div> <div class="yui3-g"> <div class="info yui3-u-2-3"> <h1> <span class="avatar user-avatar"> <img src="{{avatar_url}}" alt="{{login}}'s avatar" /> </span> <span class="user-login">{{login}}</span> {{#if name}} <span class="user-name">({{name}})</span> {{/if}} </h1> </div> <div class="stats yui3-u-1-3"> <ul class="yui3-g"> <li class="user-repos yui3-u-1-2"> <b>{{public_repos}}</b> {{public_repos_label}} </li> <li class="user-followers yui3-u-1-2"> <b>{{followers}}</b> {{followers_label}} </li> </ul> </div> </div> </script> <!-- RepoList Template: Template HTML used by the `UserPageView` to render the main section of a user's page in the app; this displays the list of project repos for the GitHub user. --> <script id="t-repo-list" type="text/x-handlebars-template"> <h2 class="no-toc">Public Repositories</h2> <ul class="repos"> {{#each repos}} <li id="{{clientId}}" class="repo yui3-g"> <div class="repo-name yui3-u-2-3"> <h3 class="no-toc"> <a href="#/github/{{owner.login}}/{{name}}/">{{name}}</a> </h3> <p class="repo-desc">{{description}}</p> </div> <div class="repo-stats yui3-u-1-3"> <ul class="yui3-g"> <li class="repo-lang yui3-u-1-3"> <b>{{language}}</b> </li> <li class="repo-watchers yui3-u-1-3"> <b>{{watchers}}</b> <span>{{watchers_label}}</span> </li> <li class="repo-forks yui3-u-1-3"> {{#if forks}} <b>{{forks}}</b> <span>{{forks_label}}</span> {{/if}} </li> </ul> </div> </li> {{/each}} </ul> </script> <!-- Repo Template: Template HTML used by the `RepoPageView` to render the header section of a repo page in the app; this displays info and stats about a GitHub user's repo. --> <script id="t-repo" type="text/x-handlebars-template"> <div class="yui3-g"> <p class="back yui3-u-1-2"> <a href="#/github/{{owner.login}}/">« Select Repository</a> </p> <p class="view-on-github yui3-u-1-2"> <a href="{{html_url}}">View on GitHub</a> </p> </div> <div class="yui3-g"> <div class="info yui3-u-2-3"> <h1> <span class="avatar user-avatar"> <img src="{{owner.avatar_url}}" alt="{{owner.login}}'s avatar" /> </span> <span class="user-login">{{owner.login}}</span> / <span class="repo-name">{{name}}</span> </h1> </div> <div class="stats yui3-u-1-3"> <ul class="yui3-g"> <li class="repo-watchers yui3-u-1-2"> <b>{{watchers}}</b> {{watchers_label}} </li> <li class="repo-forks yui3-u-1-2"> <b>{{forks}}</b> {{forks_label}} </li> </ul> </div> </div> </script> <!-- ContributorList Template: Template HTML used by the `RepoPageView` to render the main section of a repo page in the app; this displays the list of contributors to the GitHub project. --> <script id="t-contributor-list" type="text/x-handlebars-template"> <h2 class="no-toc">Project Contributors ({{num}})</h2> <ul class="contributors"> {{#each contributors}} <li class="contributor"> <a class="avatar" href="#/github/{{login}}/" title="{{login}} has {{contributions}} {{contributions_label}}"> <img alt="{{login}}'s avatar" src="{{avatar_url}}" /> <span class="contributor-name">{{login}}</span> <span class="contributor-contributions">{{contributions}}</span> </a> </li> {{/each}} </ul> </script>
CSS
To make our app look nice, we'll add some CSS to style the markup we've setup in our templates. We'll also use the excellent CSS Grids in YUI to help us layout the different parts of the app's interface (you might have noticed the references to the CSS Grids classes in our templates).
<!-- Include YUI CSS Grids on the page if you haven't already. --> <link rel="stylesheet" href="http://yui.yahooapis.com/3.8.0/build/cssgrids/grids-min.css" /> <style scoped> /*-- Override Styles ---------------------------------------------------------*/ .example { padding: 0 !important; } /*-- Common Styles -----------------------------------------------------------*/ #github-app h1 { padding: 0; } #github-app .avatar, #github-app .avatar img { display: block; width: 76px; height: 76px; border: 0; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; } #github-app .avatar { display: inline-block; *display: inline; zoom: 1; height: 76px; padding: 2px; } #github-app .avatar:hover, #github-app .user-avatar { -webkit-box-shadow: 0 0 2px rgba(0,0,0,0.25); -moz-box-shadow: 0 0 2px rgba(0,0,0,0.25); box-shadow: 0 0 2px rgba(0,0,0,0.25); } #github-app .back, #github-app .view-on-github { margin-top: 0; } #github-app .view-on-github { text-align: right; } #github-app .info h1 { margin: 0; } #github-app .info .user-avatar, #github-app .info .user-login, #github-app .info .user-name, #github-app .info .repo-name { vertical-align: middle; } #github-app .info .user-avatar { margin-right: 10px; } #github-app .info .user-name { font-weight: 400; } #github-app .stats ul { margin: 0; padding: 8px; color: #30418C; background: #F0F1F8; border: 1px solid #D4D8EB; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; text-align: center; list-style: none; } #github-app .stats b { display: block; font-size: 36px; line-height: 44px; } /*-- HomePage Styles ---------------------------------------------------------*/ #github-app .home-page { padding: 1em; } #github-app .home-page h1, #github-app .home-page p { display: block; text-align: center; } #github-app .home-page label, #github-app .home-page input, #github-app .home-page button { font-size: 20px; } #github-app .home-page label { color: #A6A6A6; } #github-app .home-page input { display: inline-block; *display: inline; zoom: 1; font-family: Helvetica, sans-serif; line-height: normal; margin: 5px auto 0; padding: 6px; width: 200px; text-align: left; } /*-- UserPage Styles ---------------------------------------------------------*/ #github-app .user-page { padding: 1em; } #github-app .repos { padding-left: 0; list-style: none; border-top: 1px solid #E5E6F1; margin: 0 -13px; } #github-app .repo { margin: 1px 0; padding: 0 13px 6px; color: #30418C; background: #F9F9FC; cursor: pointer; border-bottom: 1px solid #E5E6F1; } #github-app .repo:hover { background: #F1F1F4; } #github-app .repo-name h3 { margin: 6px 0 0 0; } #github-app .repo-stats { margin-top: 6px; background: url(../assets/app/arrow.gif) right center no-repeat; } #github-app .repo-stats ul { list-style: none; padding-left: 0; height: 47px; line-height: 47px; } #github-app .repo-stats b { display: block; } #github-app .repo-stats span { display: block; font-size: 11px; color: #B6BCD7; color: rgba(48, 65, 140, 0.40); } #github-app .repo-stats .repo-watchers, #github-app .repo-stats .repo-forks { text-align: center; line-height: 11px; margin-top: 15px; } #github-app .repo-desc { margin: 10px 20px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #B6BCD7; color: rgba(48, 65, 140, 0.40); } /*-- RepoPage Styles ---------------------------------------------------------*/ #github-app .repo-page { padding: 1em; } #github-app .contributors { margin-left: -5px; margin-right: -5px; padding-left: 0; list-style: none; } #github-app .contributor { margin: 5px; display: inline-block; *display: inline; zoom: 1; } #github-app .contributor .avatar { position: relative; display: block; } #github-app .contributor .avatar:link, #github-app .contributor .avatar:visited, #github-app .contributor .avatar:hover, #github-app .contributor .avatar:active { text-decoration: none; } #github-app .contributor-name, #github-app .contributor-contributions { position: absolute; left: 0; margin: 2px; padding: 0 4px; width: 68px; font-size: 11px; color: #fff; background: #000; background: rgba(0,0,0, 0.2); text-shadow: 0 1px 0 rgba(0,0,0, 0.4); } #github-app .contributor-name { bottom: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -webkit-border-bottom-right-radius: 3px; -webkit-border-bottom-left-radius: 3px; -moz-border-radius-bottomright: 3px; -moz-border-radius-bottomleft: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; } #github-app .contributor-contributions { top: 0; -webkit-border-top-left-radius: 3px; -webkit-border-top-right-radius: 3px; -moz-border-radius-topleft: 3px; -moz-border-radius-topright: 3px; border-top-left-radius: 3px; border-top-right-radius: 3px; } </style>
JavaScript
Our GitHub Contributors app will have its code organized into three main areas:
-
Models & Model Lists which manage, store, and load the data for our application. All the data will be fetched from GitHub's API; we'll extract the logic for interacting with the API out into a
GithubSync
extension so it can be reused. -
Views which represent discrete, render-able, pieces of our app's user interface by bringing together our models and model lists with our templates and handling the user interactions that occur within their containment.
-
The App component, a navigation and view manager, provides a foundation and structure which allows us to declaratively connect the parts of our application together into higher-level "pages" and handles the user navigating between them.
All of these things will live inside the following YUI instance:
<!-- Include YUI on the page if you haven't already. --> <script src="http://yui.yahooapis.com/3.8.0/build/yui/yui-min.js"></script> <script> YUI().use('app', 'handlebars', 'jsonp', 'cssbutton', function (Y) { var User, Repo, RepoList, Contributor, ContributorList, UserView, RepoView, RepoListView, ContributorListView, HomePageView, UserPageView, RepoPageView, ContributorsApp; // ... Add the code from the following sections here! ... }); </script>
GithubSync
This is an extension that provides a read-only sync implementation for GitHub's API which can be mixed-in by a Y.Model
or Y.ModelList
subclass. This extension is used by User
, RepoList
, and ContributorList
as we'll see below.
We have this ability to create mixable feature extensions, like this sync implementation, because Model
and ModelList
are Y.Base
-based.
function GithubSync() {} GithubSync.API_ORIGIN = 'https://api.github.com'; GithubSync.prototype = { // Intended to be overridden with a GitHub API endpoint URL. githubURL: '/', // Can be overridden to customize the actual URL used when making the // request; this way the `githubURL` can be more of a template, and this // method can substitute place-holders in the template with specific values. buildURL: function () { return this.githubURL; }, // Provides an implementation for a Model/ModelList's `sync()` method which // can read data from GitHub's API. sync: function (action, options, callback) { Y.Lang.isFunction(callback) || (callback = function () {}); // Enforce ready-only constraint. if (action !== 'read') { return callback('Read-only'); } // Creates the API URL and adds params for 100 items per-page and a // JSONP callback placeholder. var url = this.buildURL() + '?per_page=100&callback={callback}'; // Performs JSONP request to GitHub's API. Y.jsonp(GithubSync.API_ORIGIN + url, function (res) { var meta = res.meta, data = res.data; // Check for a successful response, otherwise return the error // message returned by the GitHub API. if (meta.status >= 200 && meta.status < 300) { callback(null, res); } else { callback(data.message, res); } }); }, // Provides an implementation for a Model/ModelList's `parse()` method which // simply returns the `data` object or array from the response JSON. parse: function (res) { return res.data; } };
Models & Model Lists
To keep our code organized and maintainable, we want a clear separation of concerns. Let's start by looking at our application from a data-centric point of view. We've already reviewed the GitHub API and abstracted-out the communication with the API into a GithubSync
extension which we'll mix into certain Models and Model Lists of our app.
Our app has three main entities: users, repositories, and contributors; all of which clearly fit with our application's features. To model these entities, we'll create the following Y.Model
subclasses: User
, Repo
, and Contributor
. We'll also need Y.ModelList
subclasses for our Repo
and Contributor
models: RepoList
, and ContributorList
.
Let's look at each of these in detail:
User
The User
model contains only the data attributes from the GitHub Users API which are relevant to the app. The GithubSync
extension is mixed-in and provides the necessary communication with this API.
User = Y.Base.create('user', Y.Model, [GithubSync], { githubURL: '/users/{user}', buildURL: function () { return Y.Lang.sub(this.githubURL, { user: this.getAsURL('login') }); } }, { // These attributes correspond to the GitHub user data we care about: // http://developer.github.com/v3/users/ ATTRS: { login : {value: null}, name : {value: null}, html_url : {value: null}, avatar_url : {value: null}, public_repos : {value: 0}, followers : {value: 0} } });
Repo
The Repo
model contains only the data attributes from the GitHub Repos API which are relevant to the app.
Repo = Y.Base.create('repo', Y.Model, [], { // The `id` attribute for this Model will be an alias for `name`. idAttribute: 'name' }, { // These attributes correspond to the GitHub repo data we care about: // http://developer.github.com/v3/repos/ ATTRS: { name : {value: null}, html_url : {value: null}, description: {value: null}, watchers : {value: 0}, forks : {value: 0}, language : {value: null}, owner : {value: null} } });
RepoList
Our app will display a list of Repo
s for a User
. The RepoList
class will add collection semantics for Repo
s by extending Y.ModelList
. The GithubSync
extension is also mixed-in to provide communication with the GitHub Repos API.
RepoList = Y.Base.create('repoList', Y.ModelList, [GithubSync], { model : Repo, githubURL: '/users/{user}/repos', buildURL: function () { // This list's `user` is used to build the API URL. return Y.Lang.sub(this.githubURL, { user: this.get('user').getAsURL('login') }); }, // Override the default `comparator()` so items are sorted by most number of // watchers to least number of watchers. comparator: function (repo) { // Note the minus sign. return -repo.get('watchers'); } }, { ATTRS: { // A `RepoList` has a `User` associated with it, this allows for a // loose-coupling between a user and set of repositories. user: {value: null} } });
Contributor
The Contributor
model extends the User
model and adds one additional data attribute, contributions
.
Contributor = Y.Base.create('contributor', User, [], {}, { ATTRS: { contributions: {value: 0} } });
ContributorList
Our app will display a list of Contributor
s for a Repo
. The ContributorList
class will add collection semantics for Contributor
s by extending Y.ModelList
. The GithubSync
extension is also mixed-in to provide communication with the GitHub Repos API.
ContributorList = Y.Base.create('contributorList', Y.ModelList, [GithubSync], { model : Contributor, githubURL: '/repos/{user}/{repo}/contributors', buildURL: function () { // This list's `repo` is used to build the API URL. var repo = this.get('repo'); return Y.Lang.sub(this.githubURL, { user: repo.getAsURL('owner.login'), repo: repo.getAsURL('name') }); }, // Override the default `comparator()` so items are sorted by most number of // contributors to least number of contributors. comparator: function (contributor) { // Note the minus sign. return -contributor.get('contributions'); } }, { ATTRS: { // A `ContributorList` has a `Repo` associated with it, this allows for // a loose-coupling between a repo and set of contributors. repo: {value: null} } });
Views
We've separated our data-related logic from our presentation by creating our models and model lists; we'll continue this by keeping our presentation-logic separate from our templates, and create View classes which contain the presentation-related logic that can render a model or model list using a template.
The data model for our app consists of many numerical values which our views will need to display with labels. Before we dig into the views, let's first create a helper function we can use to add these labels to the data object we pass to our templates:
// A helper function used by the Views to create labels for numerical fields // which are correctly pluralized, and puts the label back on the `data` object. function addLabel(data, field, label) { var num = data[field]; data[field] || (data[field] = '0'); data[field + '_label'] = num === 1 ? label : (label + 's'); }
The views for our app will be pretty trivial; they mainly just prepare the model or model list data and apply it to the corresponding template during rendering. Usually your views would involve handling of DOM events created by user interactions and binding to the model or model list changes so the view can be updated or re-rendered. We'll create the following views by sub-classing Y.View
: UserView
, RepoView
, RepoListView
, and ContributorListView
.
Let's look at where and how each of the views will be used:
UserView
The UserView
is responsible for the rendering the info and stats about the currently-selected GitHub user. Using the User Template this view renders a User
Model to look like this:
UserView = Y.Base.create('userView', Y.View, [], { // Compiles the User Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-user').getHTML()), render: function () { // Retrieves all of the model instance's data as a simple JSON struct. var user = this.get('model').toJSON(), content; // Add proper pluralized labels for numerical data fields. addLabel(user, 'public_repos', 'Public Repo'); addLabel(user, 'followers', 'Follower'); // Applies the `User` model data to the User Template and sets the // resulting HTML as the contents of this view's container. content = this.template(user); this.get('container').setHTML(content); return this; } });
RepoView
The RepoView
is responsible for the rendering the info and stats about the currently-selected GitHub repository. Using the Repo Template this view renders a Repo
Model to look like this:
RepoView = Y.Base.create('repoView', Y.View, [], { // Compiles the Repo Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-repo').getHTML()), render: function () { // Retrieves all of the model instance's data as a simple JSON struct. var repo = this.get('model').toJSON(), content; // Add proper pluralized labels for numerical data fields. addLabel(repo, 'watchers', 'Watcher'); addLabel(repo, 'forks', 'Fork'); // Applies the `Repo` model data to the Repo Template and sets the // resulting HTML as the contents of this view's container. content = this.template(repo); this.get('container').setHTML(content); return this; } });
RepoListView
The RepoListView
is responsible for the rendering a list of repositories/projects for the currently-selected GitHub user. Using the RepoList Template this view renders a RepoList
to look like this:
RepoListView = Y.Base.create('repoListView', Y.View, [], { // Compiles the RepoList Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-repo-list').getHTML()), // Attach DOM events for the view. The `events` object is a mapping of // selectors to an object containing one or more events to attach to the // node(s) matching each selector. events: { '.repo': { click: 'selectRepo' } }, initializer: function () { // The `selectRepo` event is fired when the user chooses a GitHub repo // to view its contributors. This event will bubble up to the // `ContributorsApp` via the `UserPageView` view when it is the app's // `activeView`. this.publish('selectRepo', {preventable: false}); }, render: function () { var repos = this.get('modelList'), reposData, content; // Iterates over all `Repo` models in the list and retrieves each model // instance's data as a simple JSON structs and collects it in an array. reposData = repos.map(function (repo) { var data = repo.toJSON(); // Add `clientId` to the data, this is ignored by `toJSON()`. This // will be used by the template and is an easy way to regain access // to the associated Repo model. data.clientId = repo.get('clientId'); // Add proper pluralized labels for numerical data fields. addLabel(data, 'watchers', 'Watcher'); addLabel(data, 'forks', 'Fork'); return data; }); // Applies the `RepoList` data to the RepoList Template and sets the // resulting HTML as the contents of this view's container. content = this.template({repos: reposData}); this.get('container').setHTML(content); return this; }, // Called when the user clicks anywhere on a repo line-item in our list // of repos. This will retreive the corresponding Repo model instance // from the RepoList via the `clientId`. selectRepo: function (e) { // Noop when the element clicked is an anchor, we'll let the link // preform its default action. if (e.target.test('a')) { return; } // Because we've rendered our list of repos with the `clientId` as the // `id` attribute for each repo node, we can use the client id to // retreive the Repo model instance from the RepoList. var repos = this.get('modelList'), repo = repos.getByClientId(e.currentTarget.get('id')); if (repo) { this.fire('selectRepo', {repo: repo}); } } });
ContributorListView
The ContributorListView
is responsible for the rendering a list of contributors for the currently-selected GitHub repository/project. Using the ContributorList Template this view renders a ContributorList
to look like this:
ContributorListView = Y.Base.create('contributorListView', Y.View, [], { // Compiles the ContributorList Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-contributor-list').getHTML()), render: function () { var contributors = this.get('modelList'), contributorsData, content; // Iterates over all `Contributor` models in the list and retrieves a // sub-set of each model instance's data as a simple JSON structs and // collects it in an array. contributorsData = contributors.map(function (contributor) { // Only a few of the `Contributor` data attributes are needed. var data = contributor.getAttrs(['login', 'avatar_url', 'contributions']); // Add proper pluralized labels for numerical data fields. addLabel(data, 'contributions', 'Contribution'); return data; }); // Applies the `ContributorList` data to the ContributorList Template // along with the total number of contributors for the repo. content = this.template({ num : contributors.size(), contributors: contributorsData }); // Sets the resulting HTML from apply the data to the template as the // contents of this view's container. this.get('container').setHTML(content); return this; } });
App
At this point we have the code for our app organized such that our models, views, and templates have distinct boundaries and roles while being loosely-coupled. We have a great separation of concerns, but now we need a way to group these pieces into "pages", create a URL-space for the app, and respond when the user navigates around our app.
Y.App
provides a foundation and structure for creating a top-level application component which is pefect for tying together the parts of an application. We'll create a Y.App
subclass that will serve as our navigation and view manager; this is where we'll define our URL-space, handle when the user navigates to those URLs, and respond by fetching the data we need and showing the corresponding page of our app.
In the section that follow, we'll define our app's URL-space and what data we'll need to respond at each URL, create some page-level views, then implement our Y.App
subclass: ContributorsApp
.
Page Views
We will now group our Views into "pages" to implement the three main features of our app. We'll combine the views we've already created into composite page-level views: HomePageView
, UserPageView
, and RepoPageView
.
Let's look at each page view in some detail:
HomePageView
This view will handle the user interaction of choosing a particular GitHub user by listening for changes to a text input after a username is entered. The view will also fire a changeUser
event to which our app can respond.
HomePageView = Y.Base.create('homePageView', Y.View, [], { // Compiles the HomePage Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-home').getHTML()), // Attach DOM events for the view. The `events` object is a mapping of // selectors to an object containing one or more events to attach to the // node(s) matching each selector. events: { 'button': { click: 'changeUser' }, 'input': { keypress: 'enter' } }, initializer: function () { // The `changeUser` event is fired when the user chooses a GitHub user // to start browsing. This event will bubble up to the `ContributorsApp` // when this view is the app's `activeView`. this.publish('changeUser', {preventable: false}); }, render: function () { // Retrieves just the `login` of the `User` model instance and applies // it to the HomePage Template. var user = this.get('model'), content = this.template(user.getAttrs(['login'])); // Adds the "home-page" CSS class to aid styling and sets the resulting // HTML as the contents of this view's container. this.get('container').addClass('home-page').setHTML(content); return this; }, // Called when the user clicks the "Show Repos" button. This will retrieve // the GitHub username from the text `<input>` and fire the `changeUser` // event, passing on the username to the app. changeUser: function () { var user = this.get('container').one('input').get('value'); if (user) { this.fire('changeUser', {user: user}); } }, // Called when the user types inside the text `<input>`; when the "enter" // key is pressed, this will call the `changeUser()` method. enter: function (e) { // Check for "enter" keypress. if (e.keyCode === 13) { this.changeUser(); } } });
UserPageView
This view is a composite of a UserView
and a RepoListView
which will display the information and stats about a GitHub user along with a list of their public repositories/projects.
UserPageView = Y.Base.create('userPageView', Y.View, [], { initializer: function () { var user = this.get('model'), repos = this.get('modelList'); // This view serves as a "page"-level view containing two sub-views to // which it delegates rendering and stitches together the resulting UI. // Sub-views are created to render the `User` and `RepoList`. this.userView = new UserView({model: user}); this.repoListView = new RepoListView({modelList: repos}); // This will cause the sub-views' custom events to bubble up to here. this.userView.addTarget(this); this.repoListView.addTarget(this); }, // This destructor is specified so this view's sub-views can be properly // destroyed and cleaned up. destructor: function () { this.userView.destroy(); this.repoListView.destroy(); delete this.userView; delete this.repoListView; }, render: function () { // A document fragment is created to hold the resulting HTML created // from rendering the two sub-views. var content = Y.one(Y.config.doc.createDocumentFragment()); // This renders each of the two sub-views into the document fragment, // then sets the fragment as the contents of this view's container. content.append(this.userView.render().get('container')); content.append(this.repoListView.render().get('container')); // Adds the "user-page" CSS class to aid styling and sets the document // fragment containing the two rendered sub-views as the contents of // this view's container. this.get('container').addClass('user-page').setHTML(content); return this; } });
RepoPageView
This view is a composite of a RepoView
and a ContributorListView
which will display the information and stats about a GitHub user's particular repository/project along with a list of all the GitHub users which are contributors to that project.
RepoPageView = Y.Base.create('repoPageView', Y.View, [], { initializer: function () { var repo = this.get('model'), contributors = this.get('modelList'); // This view serves as a "page"-level view containing two sub-views to // which it delegates rendering and stitches together the resulting UI. // Sub-views are created to render the `RepoView` and // `ContributorListView`. this.repoView = new RepoView({model: repo}); this.contributorListView = new ContributorListView({modelList: contributors}); // This will cause the sub-views' custom events to bubble up to here. this.repoView.addTarget(this); this.contributorListView.addTarget(this); }, // This destructor is specified so this view's sub-views can be properly // destroyed and cleaned up. destructor: function () { this.repoView.destroy(); this.contributorListView.destroy(); delete this.repoView; delete this.contributorListView; }, render: function () { // A document fragment is created to hold the resulting HTML created // from rendering the two sub-views. var content = Y.one(Y.config.doc.createDocumentFragment()); // This renders each of the two sub-views into the document fragment, // then sets the fragment as the contents of this view's container. content.append(this.repoView.render().get('container')); content.append(this.contributorListView.render().get('container')); // Adds the "repo-page" CSS class to aid styling and sets the document // fragment containing the two rendered sub-views as the contents of // this view's container. this.get('container').addClass('repo-page').setHTML(content); return this; } });
Routes
With our app grouped into three "pages", we need to make these pages accessible via URLs; we'll do this by defining the following routes:
-
/
: Displays theHomePageView
and uses the currentUser
model to pre-fill the text input. -
/github/:user/
: Displays theUserPageView
which requires aUser
model andRepoList
model list which we'll setup through the following steps:-
Retrieve the GitHub username from the
:user
request parameter and create aUser
model. - Load our user model's data from the GitHub API.
-
Assign our fully-loaded user model to a
RepoList
model list instance. -
Load our repos model-list to get all our user's
Repo
s from the GitHub API.
-
Retrieve the GitHub username from the
-
/github/:user/:repo/
: Displays theRepoPageView
which requires aRepo
model andContributorList
model list which we'll setup through the following steps (Note: how steps 1–4 are the same as above):-
Retrieve the GitHub username from the
:user
request parameter and create aUser
model. - Load our user model's data from the GitHub API.
-
Assign our fully-loaded user model to a
RepoList
model list instance. -
Load our repos model-list to get all our user's
Repo
s from the GitHub API. -
Retrieve the specific repository name from the
:repo
request parameter and find the correspondingRepo
model in ourRepoList
. -
Assign our repo model to a
ContributorList
model list instance. - Load our contributors model-list from the GitHub API to get all of the GitHub users who have contributed to the project.
-
Retrieve the GitHub username from the
Notice how our /github/:user/
and /github/:user/:repo/
routes have very similar data requirements, specifically steps 1–4, we can use the routing infrastructure provided by Y.App
to create reusable "middleware" which both of these routes can use. Each piece of middleware can be responsible for fetching a single data object we'll need, essentially building up the request object with everything needed to fulfill the request.
Let's see how our page-level views and route handlers work together by looking at ContributorsApp
:
ContributorsApp
Here is our subclass of Y.App
where we have defined our three page-level views, our app's URL-space, and our route handlers. The infrastructure provided by Y.App
will take care of managing our page-views and dispatching to our route handlers as our users navigate through our app.
ContributorsApp = Y.Base.create('contributorsApp', Y.App, [], { // This is where we can declare our page-level views and define the // relationship between the "pages" of our application. We can later use the // `showView()` method to create and display these views. views: { homePage: { type: HomePageView }, userPage: { type: UserPageView }, repoPage: { type : RepoPageView, parent: 'userPage' } }, initializer: function () { // Here we register a listener for the `HomePageView`'s `changeUser` // event. When the `HomePageView` is the `activeView`, its events will // bubble up to this app instance. this.on('*:changeUser', this.navigateToUser); // Here we register a listener for the `RepoListView`'s `selectRepo` // event. The `RepoListView`'s events bubble up to the `UserPageView`, // so when it is the `activeView`, its events will bubble up to this // app instance. this.on('*:selectRepo', this.navigateToRepo); // Once our app is ready, we'll either dispatch to our route handlers if // the current URL matches one of our routes, or we'll simply show the // `HomePageView`. this.once('ready', function (e) { if (this.hasRoute(this.getPath())) { this.dispatch(); } else { this.showHomePage(); } }); }, // -- Event Handlers ------------------------------------------------------- // When called, this will navigate the application to the user-page for the // GitHub username specified on the event facade. This will cause our app to // dispatch to its route handlers along with updating the URL. navigateToUser: function (e) { var activeView = this.get('activeView'); // We want to add a history entry for "/" when we're showing the home // page, but the app's current path isn't already "/". This provides // proper back button support and helps get around a side effect of // using hash based URLs in this example. if (activeView instanceof HomePageView && this.getPath() !== '/') { // Adds history entry for "/" so we can get back to the home page // via the back button. this.save('/'); } this.navigate('/github/' + e.user + '/'); }, // When called, this will navigate the application to the repo-page for the // GitHub repository specified on the event facade. This will cause our app // to dispatch to its route handlers along with updating the URL. navigateToRepo: function (e) { var repo = e.repo; this.navigate('/github/' + repo.get('owner.login') + '/' + repo.get('name') + '/'); }, // -- Route Handlers ------------------------------------------------------- // Route middleware whose job is to make sure a `User` model for the // specified GitHub username is fully-loaded and placed on the request // object for other route handlers. handleUser: function (req, res, next) { var username = req.params.user, user = this.get('user'), self = this; // When the current `User` model set on the app is new or the specified // GitHub username from the URL is different, a new user model instance // is created, loaded, and set on this app before adding it to the // request object. if (username === user.get('login') && !user.isNew()) { // Places a reference to the user model on the request object before // continuing. req.user = user; next(); } else { // Create a new `User` model instance using the specified GitHub // username from the URL. user = new User({login: username}); // Load the user's data from the GitHub API, then sent the user // model on both this app and the request object before continuing. user.load(function () { self.set('user', user); req.user = user; next(); }); } }, // Route middleware whose job is to make sure a fully-loaded `RepoList` // instance is placed on the request object. It is assumed that // `handleUser()` middleware has already placed the `User` model on the // request object. handleRepos: function (req, res, next) { var user = req.user, repos = this.get('repos'); // Adds a reference to this app's `RepoList` to the request object. req.repos = repos; // This makes sure the `RepoList` is loaded for the current user. if (user === repos.get('user')) { next(); } else { // A fade transition is preferred when we've switched users, so it // is added to the response object. res.transition = 'fade'; // Sets the current user model on the `RepoList` instance and loads // the repos for the given user before continuing. repos.set('user', user).load(function () { next(); }); } }, // Route middleware whose job is to make sure a fully-loaded // `ContributorList` instance is placed on the request object. It is assumed // that the `handleUser()` and `handleRepos()` middleware have already // placed the `User` model and `RepoList` model-list on the request object. handleRepo: function (req, res, next) { // This uses data from the request object to look for a `Repo` model // instance in the `RepoList`. var repoId = req.params.repo, repos = req.repos, repo = repos.getById(repoId), contributors = this.get('contributors'); // We error-out when the specified repo name does not exist in the list // of repos for the current GitHub user. if (!repo) { return next('GitHub repository was not found.'); } // Adds a reference to the `Repo` model and `ContributorList` to the // request object. req.repo = repo; req.contributors = contributors; // This makes sure the `ContributorList` is loaded for the current repo. if (repo === contributors.get('repo')) { next(); } else { // Sets the current repo model on the `ContributorList` instance and // loads the contributors before continuing. contributors.set('repo', repo).load(function () { next(); }); } }, // This is called when the URL is "/" and will show our app's home page. showHomePage: function (req) { this.showView('homePage', { model: this.get('user') }); }, // This is the route for "/github/:user/" URLs and will show our app's user // page which lists the user's repos. showUserPage: function (req, res) { this.showView('userPage', { model : req.user, modelList: req.repos }, { // Overrides the default transition with the preferred one, if set. transition: res.transition }); }, // This is the route for "/github/:user/:repo/" URLs and will show our app's // repo page which lists the contributors to the repo/project. showRepoPage: function (req, res) { this.showView('repoPage', { model : req.repo, modelList: req.contributors }, { // Overrides the default transition with the preferred one, if set. transition: res.transition }); } }, { ATTRS: { // These attributes will be used by the app to hold its current state, // and they will be accessed and modified by our route handlers. user : {value: new User()}, repos : {value: new RepoList()}, contributors: {value: new ContributorList()}, // Our app will use more advanced routes where multiple callbacks // (or middleware) will be used to fulfill a "request", allowing us to // encapsulate and reuse our data processing logic. Note: the order in // which the routes and their middleware are defined is significant. routes: { value: [ {path: '/', callbacks: 'showHomePage'}, {path: '/github/:user/', callbacks: [ 'handleUser', 'handleRepos', 'showUserPage' ]}, {path: '/github/:user/:repo/', callbacks: [ 'handleUser', 'handleRepos', 'handleRepo', 'showRepoPage' ]} ] } } });
Initializing the App
Finally we can create a new instance of our ContributorsApp
, render it to the page, and be good to go!
// Create and render a new instance of our `ContributorsApp`! new ContributorsApp({ // We force this to false for this example app because there is no server. serverRouting: false, // Here we set our app's rendering container, and restrict which links on // the page should cause the app to navigate. container : '#github-app', linkSelector: '#github-app a', // Enable view transitions when users are navigating around the app. transitions: true, // We'll define the default GitHub user to be "yui". user: new User({login: 'yui'}) }).render();
Complete Example Source
<!-- Include YUI CSS Grids on the page if you haven't already. --> <link rel="stylesheet" href="http://yui.yahooapis.com/3.8.0/build/cssgrids/grids-min.css" /> <style scoped> /*-- Override Styles ---------------------------------------------------------*/ .example { padding: 0 !important; } /*-- Common Styles -----------------------------------------------------------*/ #github-app h1 { padding: 0; } #github-app .avatar, #github-app .avatar img { display: block; width: 76px; height: 76px; border: 0; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; } #github-app .avatar { display: inline-block; *display: inline; zoom: 1; height: 76px; padding: 2px; } #github-app .avatar:hover, #github-app .user-avatar { -webkit-box-shadow: 0 0 2px rgba(0,0,0,0.25); -moz-box-shadow: 0 0 2px rgba(0,0,0,0.25); box-shadow: 0 0 2px rgba(0,0,0,0.25); } #github-app .back, #github-app .view-on-github { margin-top: 0; } #github-app .view-on-github { text-align: right; } #github-app .info h1 { margin: 0; } #github-app .info .user-avatar, #github-app .info .user-login, #github-app .info .user-name, #github-app .info .repo-name { vertical-align: middle; } #github-app .info .user-avatar { margin-right: 10px; } #github-app .info .user-name { font-weight: 400; } #github-app .stats ul { margin: 0; padding: 8px; color: #30418C; background: #F0F1F8; border: 1px solid #D4D8EB; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; text-align: center; list-style: none; } #github-app .stats b { display: block; font-size: 36px; line-height: 44px; } /*-- HomePage Styles ---------------------------------------------------------*/ #github-app .home-page { padding: 1em; } #github-app .home-page h1, #github-app .home-page p { display: block; text-align: center; } #github-app .home-page label, #github-app .home-page input, #github-app .home-page button { font-size: 20px; } #github-app .home-page label { color: #A6A6A6; } #github-app .home-page input { display: inline-block; *display: inline; zoom: 1; font-family: Helvetica, sans-serif; line-height: normal; margin: 5px auto 0; padding: 6px; width: 200px; text-align: left; } /*-- UserPage Styles ---------------------------------------------------------*/ #github-app .user-page { padding: 1em; } #github-app .repos { padding-left: 0; list-style: none; border-top: 1px solid #E5E6F1; margin: 0 -13px; } #github-app .repo { margin: 1px 0; padding: 0 13px 6px; color: #30418C; background: #F9F9FC; cursor: pointer; border-bottom: 1px solid #E5E6F1; } #github-app .repo:hover { background: #F1F1F4; } #github-app .repo-name h3 { margin: 6px 0 0 0; } #github-app .repo-stats { margin-top: 6px; background: url(../assets/app/arrow.gif) right center no-repeat; } #github-app .repo-stats ul { list-style: none; padding-left: 0; height: 47px; line-height: 47px; } #github-app .repo-stats b { display: block; } #github-app .repo-stats span { display: block; font-size: 11px; color: #B6BCD7; color: rgba(48, 65, 140, 0.40); } #github-app .repo-stats .repo-watchers, #github-app .repo-stats .repo-forks { text-align: center; line-height: 11px; margin-top: 15px; } #github-app .repo-desc { margin: 10px 20px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #B6BCD7; color: rgba(48, 65, 140, 0.40); } /*-- RepoPage Styles ---------------------------------------------------------*/ #github-app .repo-page { padding: 1em; } #github-app .contributors { margin-left: -5px; margin-right: -5px; padding-left: 0; list-style: none; } #github-app .contributor { margin: 5px; display: inline-block; *display: inline; zoom: 1; } #github-app .contributor .avatar { position: relative; display: block; } #github-app .contributor .avatar:link, #github-app .contributor .avatar:visited, #github-app .contributor .avatar:hover, #github-app .contributor .avatar:active { text-decoration: none; } #github-app .contributor-name, #github-app .contributor-contributions { position: absolute; left: 0; margin: 2px; padding: 0 4px; width: 68px; font-size: 11px; color: #fff; background: #000; background: rgba(0,0,0, 0.2); text-shadow: 0 1px 0 rgba(0,0,0, 0.4); } #github-app .contributor-name { bottom: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -webkit-border-bottom-right-radius: 3px; -webkit-border-bottom-left-radius: 3px; -moz-border-radius-bottomright: 3px; -moz-border-radius-bottomleft: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; } #github-app .contributor-contributions { top: 0; -webkit-border-top-left-radius: 3px; -webkit-border-top-right-radius: 3px; -moz-border-radius-topleft: 3px; -moz-border-radius-topright: 3px; border-top-left-radius: 3px; border-top-right-radius: 3px; } </style> <!-- HomePage Template: Template HTML used by the `HomePageView` to render the app's home page. --> <script id="t-home" type="text/x-handlebars-template"> <h1>Contributors of GitHub Projects</h1> <p> <input id="github-app-username" type="text" value="{{login}}" placeholder="GitHub Username" /> <button class="yui3-button">Show Repos</button> </p> <p> <label for="github-app-username">(e.g. yui, davglass, rgrove)</label> </p> </script> <!-- User Template: Template HTML used by the `UserPageView` to render the header section of a user's page in the app; this displays info and stats about the GitHub user. --> <script id="t-user" type="text/x-handlebars-template"> <div class="yui3-g"> <p class="back yui3-u-1-2"> <a href="#/">« Choose Someone Different</a> </p> <p class="view-on-github yui3-u-1-2"> <a href="{{html_url}}">View on GitHub</a> </p> </div> <div class="yui3-g"> <div class="info yui3-u-2-3"> <h1> <span class="avatar user-avatar"> <img src="{{avatar_url}}" alt="{{login}}'s avatar" /> </span> <span class="user-login">{{login}}</span> {{#if name}} <span class="user-name">({{name}})</span> {{/if}} </h1> </div> <div class="stats yui3-u-1-3"> <ul class="yui3-g"> <li class="user-repos yui3-u-1-2"> <b>{{public_repos}}</b> {{public_repos_label}} </li> <li class="user-followers yui3-u-1-2"> <b>{{followers}}</b> {{followers_label}} </li> </ul> </div> </div> </script> <!-- RepoList Template: Template HTML used by the `UserPageView` to render the main section of a user's page in the app; this displays the list of project repos for the GitHub user. --> <script id="t-repo-list" type="text/x-handlebars-template"> <h2 class="no-toc">Public Repositories</h2> <ul class="repos"> {{#each repos}} <li id="{{clientId}}" class="repo yui3-g"> <div class="repo-name yui3-u-2-3"> <h3 class="no-toc"> <a href="#/github/{{owner.login}}/{{name}}/">{{name}}</a> </h3> <p class="repo-desc">{{description}}</p> </div> <div class="repo-stats yui3-u-1-3"> <ul class="yui3-g"> <li class="repo-lang yui3-u-1-3"> <b>{{language}}</b> </li> <li class="repo-watchers yui3-u-1-3"> <b>{{watchers}}</b> <span>{{watchers_label}}</span> </li> <li class="repo-forks yui3-u-1-3"> {{#if forks}} <b>{{forks}}</b> <span>{{forks_label}}</span> {{/if}} </li> </ul> </div> </li> {{/each}} </ul> </script> <!-- Repo Template: Template HTML used by the `RepoPageView` to render the header section of a repo page in the app; this displays info and stats about a GitHub user's repo. --> <script id="t-repo" type="text/x-handlebars-template"> <div class="yui3-g"> <p class="back yui3-u-1-2"> <a href="#/github/{{owner.login}}/">« Select Repository</a> </p> <p class="view-on-github yui3-u-1-2"> <a href="{{html_url}}">View on GitHub</a> </p> </div> <div class="yui3-g"> <div class="info yui3-u-2-3"> <h1> <span class="avatar user-avatar"> <img src="{{owner.avatar_url}}" alt="{{owner.login}}'s avatar" /> </span> <span class="user-login">{{owner.login}}</span> / <span class="repo-name">{{name}}</span> </h1> </div> <div class="stats yui3-u-1-3"> <ul class="yui3-g"> <li class="repo-watchers yui3-u-1-2"> <b>{{watchers}}</b> {{watchers_label}} </li> <li class="repo-forks yui3-u-1-2"> <b>{{forks}}</b> {{forks_label}} </li> </ul> </div> </div> </script> <!-- ContributorList Template: Template HTML used by the `RepoPageView` to render the main section of a repo page in the app; this displays the list of contributors to the GitHub project. --> <script id="t-contributor-list" type="text/x-handlebars-template"> <h2 class="no-toc">Project Contributors ({{num}})</h2> <ul class="contributors"> {{#each contributors}} <li class="contributor"> <a class="avatar" href="#/github/{{login}}/" title="{{login}} has {{contributions}} {{contributions_label}}"> <img alt="{{login}}'s avatar" src="{{avatar_url}}" /> <span class="contributor-name">{{login}}</span> <span class="contributor-contributions">{{contributions}}</span> </a> </li> {{/each}} </ul> </script> <!-- Include YUI on the page if you haven't already. --> <script src="http://yui.yahooapis.com/3.8.0/build/yui/yui-min.js"></script> <script> YUI().use('app', 'handlebars', 'jsonp', 'cssbutton', function (Y) { var User, Repo, RepoList, Contributor, ContributorList, UserView, RepoView, RepoListView, ContributorListView, HomePageView, UserPageView, RepoPageView, ContributorsApp; // -- Utility Functions -------------------------------------------------------- // A helper function used by the Views to create labels for numerical fields // which are correctly pluralized, and puts the label back on the `data` object. function addLabel(data, field, label) { var num = data[field]; data[field] || (data[field] = '0'); data[field + '_label'] = num === 1 ? label : (label + 's'); } // -- GithubSync --------------------------------------------------------------- function GithubSync() {} GithubSync.API_ORIGIN = 'https://api.github.com'; GithubSync.prototype = { // Intended to be overridden with a GitHub API endpoint URL. githubURL: '/', // Can be overridden to customize the actual URL used when making the // request; this way the `githubURL` can be more of a template, and this // method can substitute place-holders in the template with specific values. buildURL: function () { return this.githubURL; }, // Provides an implementation for a Model/ModelList's `sync()` method which // can read data from GitHub's API. sync: function (action, options, callback) { Y.Lang.isFunction(callback) || (callback = function () {}); // Enforce ready-only constraint. if (action !== 'read') { return callback('Read-only'); } // Creates the API URL and adds params for 100 items per-page and a // JSONP callback placeholder. var url = this.buildURL() + '?per_page=100&callback={callback}'; // Performs JSONP request to GitHub's API. Y.jsonp(GithubSync.API_ORIGIN + url, function (res) { var meta = res.meta, data = res.data; // Check for a successful response, otherwise return the error // message returned by the GitHub API. if (meta.status >= 200 && meta.status < 300) { callback(null, res); } else { callback(data.message, res); } }); }, // Provides an implementation for a Model/ModelList's `parse()` method which // simply returns the `data` object or array from the response JSON. parse: function (res) { return res.data; } }; // -- User --------------------------------------------------------------------- User = Y.Base.create('user', Y.Model, [GithubSync], { githubURL: '/users/{user}', buildURL: function () { return Y.Lang.sub(this.githubURL, { user: this.getAsURL('login') }); } }, { // These attributes correspond to the GitHub user data we care about: // http://developer.github.com/v3/users/ ATTRS: { login : {value: null}, name : {value: null}, html_url : {value: null}, avatar_url : {value: null}, public_repos : {value: 0}, followers : {value: 0} } }); // -- Repo --------------------------------------------------------------------- Repo = Y.Base.create('repo', Y.Model, [], { // The `id` attribute for this Model will be an alias for `name`. idAttribute: 'name' }, { // These attributes correspond to the GitHub repo data we care about: // http://developer.github.com/v3/repos/ ATTRS: { name : {value: null}, html_url : {value: null}, description: {value: null}, watchers : {value: 0}, forks : {value: 0}, language : {value: null}, owner : {value: null} } }); // -- RepoList ----------------------------------------------------------------- RepoList = Y.Base.create('repoList', Y.ModelList, [GithubSync], { model : Repo, githubURL: '/users/{user}/repos', buildURL: function () { // This list's `user` is used to build the API URL. return Y.Lang.sub(this.githubURL, { user: this.get('user').getAsURL('login') }); }, // Override the default `comparator()` so items are sorted by most number of // watchers to least number of watchers. comparator: function (repo) { // Note the minus sign. return -repo.get('watchers'); } }, { ATTRS: { // A `RepoList` has a `User` associated with it, this allows for a // loose-coupling between a user and set of repositories. user: {value: null} } }); // -- Contributor -------------------------------------------------------------- Contributor = Y.Base.create('contributor', User, [], {}, { ATTRS: { contributions: {value: 0} } }); // -- ContributorList ---------------------------------------------------------- ContributorList = Y.Base.create('contributorList', Y.ModelList, [GithubSync], { model : Contributor, githubURL: '/repos/{user}/{repo}/contributors', buildURL: function () { // This list's `repo` is used to build the API URL. var repo = this.get('repo'); return Y.Lang.sub(this.githubURL, { user: repo.getAsURL('owner.login'), repo: repo.getAsURL('name') }); }, // Override the default `comparator()` so items are sorted by most number of // contributors to least number of contributors. comparator: function (contributor) { // Note the minus sign. return -contributor.get('contributions'); } }, { ATTRS: { // A `ContributorList` has a `Repo` associated with it, this allows for // a loose-coupling between a repo and set of contributors. repo: {value: null} } }); // -- User View ---------------------------------------------------------------- UserView = Y.Base.create('userView', Y.View, [], { // Compiles the User Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-user').getHTML()), render: function () { // Retrieves all of the model instance's data as a simple JSON struct. var user = this.get('model').toJSON(), content; // Add proper pluralized labels for numerical data fields. addLabel(user, 'public_repos', 'Public Repo'); addLabel(user, 'followers', 'Follower'); // Applies the `User` model data to the User Template and sets the // resulting HTML as the contents of this view's container. content = this.template(user); this.get('container').setHTML(content); return this; } }); // -- Repo View ---------------------------------------------------------------- RepoView = Y.Base.create('repoView', Y.View, [], { // Compiles the Repo Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-repo').getHTML()), render: function () { // Retrieves all of the model instance's data as a simple JSON struct. var repo = this.get('model').toJSON(), content; // Add proper pluralized labels for numerical data fields. addLabel(repo, 'watchers', 'Watcher'); addLabel(repo, 'forks', 'Fork'); // Applies the `Repo` model data to the Repo Template and sets the // resulting HTML as the contents of this view's container. content = this.template(repo); this.get('container').setHTML(content); return this; } }); // -- RepoList View ------------------------------------------------------------ RepoListView = Y.Base.create('repoListView', Y.View, [], { // Compiles the RepoList Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-repo-list').getHTML()), // Attach DOM events for the view. The `events` object is a mapping of // selectors to an object containing one or more events to attach to the // node(s) matching each selector. events: { '.repo': { click: 'selectRepo' } }, initializer: function () { // The `selectRepo` event is fired when the user chooses a GitHub repo // to view its contributors. This event will bubble up to the // `ContributorsApp` via the `UserPageView` view when it is the app's // `activeView`. this.publish('selectRepo', {preventable: false}); }, render: function () { var repos = this.get('modelList'), reposData, content; // Iterates over all `Repo` models in the list and retrieves each model // instance's data as a simple JSON structs and collects it in an array. reposData = repos.map(function (repo) { var data = repo.toJSON(); // Add `clientId` to the data, this is ignored by `toJSON()`. This // will be used by the template and is an easy way to regain access // to the associated Repo model. data.clientId = repo.get('clientId'); // Add proper pluralized labels for numerical data fields. addLabel(data, 'watchers', 'Watcher'); addLabel(data, 'forks', 'Fork'); return data; }); // Applies the `RepoList` data to the RepoList Template and sets the // resulting HTML as the contents of this view's container. content = this.template({repos: reposData}); this.get('container').setHTML(content); return this; }, // Called when the user clicks anywhere on a repo line-item in our list // of repos. This will retreive the corresponding Repo model instance // from the RepoList via the `clientId`. selectRepo: function (e) { // Noop when the element clicked is an anchor, we'll let the link // preform its default action. if (e.target.test('a')) { return; } // Because we've rendered our list of repos with the `clientId` as the // `id` attribute for each repo node, we can use the client id to // retreive the Repo model instance from the RepoList. var repos = this.get('modelList'), repo = repos.getByClientId(e.currentTarget.get('id')); if (repo) { this.fire('selectRepo', {repo: repo}); } } }); // -- ContributorList View ----------------------------------------------------- ContributorListView = Y.Base.create('contributorListView', Y.View, [], { // Compiles the ContributorList Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-contributor-list').getHTML()), render: function () { var contributors = this.get('modelList'), contributorsData, content; // Iterates over all `Contributor` models in the list and retrieves a // sub-set of each model instance's data as a simple JSON structs and // collects it in an array. contributorsData = contributors.map(function (contributor) { // Only a few of the `Contributor` data attributes are needed. var data = contributor.getAttrs(['login', 'avatar_url', 'contributions']); // Add proper pluralized labels for numerical data fields. addLabel(data, 'contributions', 'Contribution'); return data; }); // Applies the `ContributorList` data to the ContributorList Template // along with the total number of contributors for the repo. content = this.template({ num : contributors.size(), contributors: contributorsData }); // Sets the resulting HTML from apply the data to the template as the // contents of this view's container. this.get('container').setHTML(content); return this; } }); // -- HomePage View ------------------------------------------------------------ HomePageView = Y.Base.create('homePageView', Y.View, [], { // Compiles the HomePage Template into a reusable Handlebars template. template: Y.Handlebars.compile(Y.one('#t-home').getHTML()), // Attach DOM events for the view. The `events` object is a mapping of // selectors to an object containing one or more events to attach to the // node(s) matching each selector. events: { 'button': { click: 'changeUser' }, 'input': { keypress: 'enter' } }, initializer: function () { // The `changeUser` event is fired when the user chooses a GitHub user // to start browsing. This event will bubble up to the `ContributorsApp` // when this view is the app's `activeView`. this.publish('changeUser', {preventable: false}); }, render: function () { // Retrieves just the `login` of the `User` model instance and applies // it to the HomePage Template. var user = this.get('model'), content = this.template(user.getAttrs(['login'])); // Adds the "home-page" CSS class to aid styling and sets the resulting // HTML as the contents of this view's container. this.get('container').addClass('home-page').setHTML(content); return this; }, // Called when the user clicks the "Show Repos" button. This will retrieve // the GitHub username from the text `<input>` and fire the `changeUser` // event, passing on the username to the app. changeUser: function () { var user = this.get('container').one('input').get('value'); if (user) { this.fire('changeUser', {user: user}); } }, // Called when the user types inside the text `<input>`; when the "enter" // key is pressed, this will call the `changeUser()` method. enter: function (e) { // Check for "enter" keypress. if (e.keyCode === 13) { this.changeUser(); } } }); // -- UserPage View ------------------------------------------------------------ UserPageView = Y.Base.create('userPageView', Y.View, [], { initializer: function () { var user = this.get('model'), repos = this.get('modelList'); // This view serves as a "page"-level view containing two sub-views to // which it delegates rendering and stitches together the resulting UI. // Sub-views are created to render the `User` and `RepoList`. this.userView = new UserView({model: user}); this.repoListView = new RepoListView({modelList: repos}); // This will cause the sub-views' custom events to bubble up to here. this.userView.addTarget(this); this.repoListView.addTarget(this); }, // This destructor is specified so this view's sub-views can be properly // destroyed and cleaned up. destructor: function () { this.userView.destroy(); this.repoListView.destroy(); delete this.userView; delete this.repoListView; }, render: function () { // A document fragment is created to hold the resulting HTML created // from rendering the two sub-views. var content = Y.one(Y.config.doc.createDocumentFragment()); // This renders each of the two sub-views into the document fragment, // then sets the fragment as the contents of this view's container. content.append(this.userView.render().get('container')); content.append(this.repoListView.render().get('container')); // Adds the "user-page" CSS class to aid styling and sets the document // fragment containing the two rendered sub-views as the contents of // this view's container. this.get('container').addClass('user-page').setHTML(content); return this; } }); // -- RepoPage View ------------------------------------------------------------ RepoPageView = Y.Base.create('repoPageView', Y.View, [], { initializer: function () { var repo = this.get('model'), contributors = this.get('modelList'); // This view serves as a "page"-level view containing two sub-views to // which it delegates rendering and stitches together the resulting UI. // Sub-views are created to render the `RepoView` and // `ContributorListView`. this.repoView = new RepoView({model: repo}); this.contributorListView = new ContributorListView({modelList: contributors}); // This will cause the sub-views' custom events to bubble up to here. this.repoView.addTarget(this); this.contributorListView.addTarget(this); }, // This destructor is specified so this view's sub-views can be properly // destroyed and cleaned up. destructor: function () { this.repoView.destroy(); this.contributorListView.destroy(); delete this.repoView; delete this.contributorListView; }, render: function () { // A document fragment is created to hold the resulting HTML created // from rendering the two sub-views. var content = Y.one(Y.config.doc.createDocumentFragment()); // This renders each of the two sub-views into the document fragment, // then sets the fragment as the contents of this view's container. content.append(this.repoView.render().get('container')); content.append(this.contributorListView.render().get('container')); // Adds the "repo-page" CSS class to aid styling and sets the document // fragment containing the two rendered sub-views as the contents of // this view's container. this.get('container').addClass('repo-page').setHTML(content); return this; } }); // -- Contributors App --------------------------------------------------------- ContributorsApp = Y.Base.create('contributorsApp', Y.App, [], { // This is where we can declare our page-level views and define the // relationship between the "pages" of our application. We can later use the // `showView()` method to create and display these views. views: { homePage: { type: HomePageView }, userPage: { type: UserPageView }, repoPage: { type : RepoPageView, parent: 'userPage' } }, initializer: function () { // Here we register a listener for the `HomePageView`'s `changeUser` // event. When the `HomePageView` is the `activeView`, its events will // bubble up to this app instance. this.on('*:changeUser', this.navigateToUser); // Here we register a listener for the `RepoListView`'s `selectRepo` // event. The `RepoListView`'s events bubble up to the `UserPageView`, // so when it is the `activeView`, its events will bubble up to this // app instance. this.on('*:selectRepo', this.navigateToRepo); // Once our app is ready, we'll either dispatch to our route handlers if // the current URL matches one of our routes, or we'll simply show the // `HomePageView`. this.once('ready', function (e) { if (this.hasRoute(this.getPath())) { this.dispatch(); } else { this.showHomePage(); } }); }, // -- Event Handlers ------------------------------------------------------- // When called, this will navigate the application to the user-page for the // GitHub username specified on the event facade. This will cause our app to // dispatch to its route handlers along with updating the URL. navigateToUser: function (e) { var activeView = this.get('activeView'); // We want to add a history entry for "/" when we're showing the home // page, but the app's current path isn't already "/". This provides // proper back button support and helps get around a side effect of // using hash based URLs in this example. if (activeView instanceof HomePageView && this.getPath() !== '/') { // Adds history entry for "/" so we can get back to the home page // via the back button. this.save('/'); } this.navigate('/github/' + e.user + '/'); }, // When called, this will navigate the application to the repo-page for the // GitHub repository specified on the event facade. This will cause our app // to dispatch to its route handlers along with updating the URL. navigateToRepo: function (e) { var repo = e.repo; this.navigate('/github/' + repo.get('owner.login') + '/' + repo.get('name') + '/'); }, // -- Route Handlers ------------------------------------------------------- // Route middleware whose job is to make sure a `User` model for the // specified GitHub username is fully-loaded and placed on the request // object for other route handlers. handleUser: function (req, res, next) { var username = req.params.user, user = this.get('user'), self = this; // When the current `User` model set on the app is new or the specified // GitHub username from the URL is different, a new user model instance // is created, loaded, and set on this app before adding it to the // request object. if (username === user.get('login') && !user.isNew()) { // Places a reference to the user model on the request object before // continuing. req.user = user; next(); } else { // Create a new `User` model instance using the specified GitHub // username from the URL. user = new User({login: username}); // Load the user's data from the GitHub API, then sent the user // model on both this app and the request object before continuing. user.load(function () { self.set('user', user); req.user = user; next(); }); } }, // Route middleware whose job is to make sure a fully-loaded `RepoList` // instance is placed on the request object. It is assumed that // `handleUser()` middleware has already placed the `User` model on the // request object. handleRepos: function (req, res, next) { var user = req.user, repos = this.get('repos'); // Adds a reference to this app's `RepoList` to the request object. req.repos = repos; // This makes sure the `RepoList` is loaded for the current user. if (user === repos.get('user')) { next(); } else { // A fade transition is preferred when we've switched users, so it // is added to the response object. res.transition = 'fade'; // Sets the current user model on the `RepoList` instance and loads // the repos for the given user before continuing. repos.set('user', user).load(function () { next(); }); } }, // Route middleware whose job is to make sure a fully-loaded // `ContributorList` instance is placed on the request object. It is assumed // that the `handleUser()` and `handleRepos()` middleware have already // placed the `User` model and `RepoList` model-list on the request object. handleRepo: function (req, res, next) { // This uses data from the request object to look for a `Repo` model // instance in the `RepoList`. var repoId = req.params.repo, repos = req.repos, repo = repos.getById(repoId), contributors = this.get('contributors'); // We error-out when the specified repo name does not exist in the list // of repos for the current GitHub user. if (!repo) { return next('GitHub repository was not found.'); } // Adds a reference to the `Repo` model and `ContributorList` to the // request object. req.repo = repo; req.contributors = contributors; // This makes sure the `ContributorList` is loaded for the current repo. if (repo === contributors.get('repo')) { next(); } else { // Sets the current repo model on the `ContributorList` instance and // loads the contributors before continuing. contributors.set('repo', repo).load(function () { next(); }); } }, // This is called when the URL is "/" and will show our app's home page. showHomePage: function (req) { this.showView('homePage', { model: this.get('user') }); }, // This is the route for "/github/:user/" URLs and will show our app's user // page which lists the user's repos. showUserPage: function (req, res) { this.showView('userPage', { model : req.user, modelList: req.repos }, { // Overrides the default transition with the preferred one, if set. transition: res.transition }); }, // This is the route for "/github/:user/:repo/" URLs and will show our app's // repo page which lists the contributors to the repo/project. showRepoPage: function (req, res) { this.showView('repoPage', { model : req.repo, modelList: req.contributors }, { // Overrides the default transition with the preferred one, if set. transition: res.transition }); } }, { ATTRS: { // These attributes will be used by the app to hold its current state, // and they will be accessed and modified by our route handlers. user : {value: new User()}, repos : {value: new RepoList()}, contributors: {value: new ContributorList()}, // Our app will use more advanced routes where multiple callbacks // (or middleware) will be used to fulfill a "request", allowing us to // encapsulate and reuse our data processing logic. Note: the order in // which the routes and their middleware are defined is significant. routes: { value: [ {path: '/', callbacks: 'showHomePage'}, {path: '/github/:user/', callbacks: [ 'handleUser', 'handleRepos', 'showUserPage' ]}, {path: '/github/:user/:repo/', callbacks: [ 'handleUser', 'handleRepos', 'handleRepo', 'showRepoPage' ]} ] } } }); // -- Go-go-gadget App! -------------------------------------------------------- // Create and render a new instance of our `ContributorsApp`! new ContributorsApp({ // We force this to false for this example app because there is no server. serverRouting: false, // Here we set our app's rendering container, and restrict which links on // the page should cause the app to navigate. container : '#github-app', linkSelector: '#github-app a', // Enable view transitions when users are navigating around the app. transitions: true, // We'll define the default GitHub user to be "yui". user: new User({login: 'yui'}) }).render(); }); </script>