Jump to Table of Contents

Example: Search Results

An application using the Flickr API to display images with a paginator control to step through the results.

This is a hefty example that may include some modules you are not familiar with, such as Y.Model, Y.View, and Y.JSONP. These modules are covered in detail on their respective pages.

Setting Up the Interface

First we need to construct the HTML for the table and controls.

<div id="demo" class="yui3-skin-sam hide-pg">
    <form>
        <input type="text" name="q" value="kitten">
        <input type="submit" value="Search" class="yui3-button">
    </form>
    <div class="results"></div>
    <div class="paginator"></div>
    <div class="loading"></div>
</div>

JavaScript

Our Flickr Search application will have four main parts:

  • A form to submit and request new images
  • Our paginator with navigation controls and direct page number options
  • Our pages that display the images
  • A Y.View that will maintain our application state

Our form is included in the markup, so we will just bind to the submit event in our application (more on this later).

Setting Up the YUI Instance

Now we need to create our YUI instance and tell it to load the modules we need.

YUI().use('paginator-core', 'model', 'view', 'transition', 'jsonp', 'querystring-stringify-simple', 'cssbutton', function (Y) {

    // Define our FlickrSearch object to contain our different components
    var FlickrSearch = {};

    // Code goes here...

});

There are a number of modules we use:

  • paginator-core: Gives us the core part of paginator to mix into a model.
  • model: Gives us a model structure to use with our paginator views
  • view: Let's us abstract our view logic into a more focused component
  • transition: Gives us the ability to fade pages in and out when swapping pages
  • jsonp: Let's us make a JSONP request to the Flickr API
  • querystring-stringify-simple: Gives us the ability to convert a simple object to a URL to pass to the Flickr API
  • cssbutton: Dresses up our search form's submit button

Paginator

The paginator we will create will consist of four control buttons (First, Previous, Next, Last) and a collection of buttons representing each page.

Our paginator will consist of one Y.Model and two Y.Views.

Paginator Model

First we'll set up the model.

FlickrSearch.PaginatorModel = Y.Base.create('pager-model', Y.Model, [Y.Paginator.Core]);

Since paginator-core contains our logic, and is built to be mixed into a Base-based component, we do not need to add any new logic. Just mix in and our model is complete!

Paginator Pages View

The first view we will create is the view for the page numbers between the left and right controls.

FlickrSearch.PaginatorPages = Y.Base.create('pager-pages', Y.View, [], {

    // We modify the the containerTemplate so our page items are contained in
    // their proper parent
    containerTemplate: '<ul/>',

    // Our page numbers are in a list element (<li>) and contain data about
    // the page (`data-page`) and control type (`data-type`)
    pageTemplate: '<li class="control page page-{page}{selected}"' +
                  ' data-type="page" data-page="{page}">{page}</li>',

    // We delegate our .page element clicks
    events: {
        '.page': {
            'click': '_pageClick'
        }
    },

    // We listen to our model's `change` event to know what page controls to
    // display and which page item to activate
    initializer: function () {
        this.get('model').after('change', this._afterChange, this);
    },

    // When we render, we simply call _buildPageList to render the page items
    render: function () {
        this.get('container').setHTML(this._buildPageList());

        return this;
    },

    // Generates a string of page items based on the current page number and
    // the number of items we can display at one time. The current page of the
    // model will be given the "selected" class name.
    //
    // For example, if our current page is 20, we have a total of 50 pages
    // and we can only display 10, our paginator should display pages
    // 15 thru 24
    //
    // Given the same scenario, if we have a total of 20 pages, our paginator
    // should display 11 thru 20
    _buildPageList: function () {
        var data = this.get('model').toJSON(),
            pages = data.totalPages,
            pagesNode = '',
            display = this.get('display'),
            min = Math.max(1, data.page - Math.floor(display / 2)),
            max = min + display - 1;

        if (max > pages) {
            min -= max - pages;
        }

        min = Math.max(1, min);
        max = Math.min(pages, max);

        for (; min <= max; min++) {
            pagesNode += Y.Lang.sub(this.pageTemplate, {
                page: min,
                selected: (data.page === min) ? ' selected' : ''
            });
        }

        return pagesNode;
    },

    // When a page is clicked, we need to push that data to the model
    _pageClick: function (e) {
        var page = e.currentTarget.getData('page');

        this.get('model').set('page', parseInt(page, 10));
    },

    // When the model's `page` or `totalItems` changes, we need to re-render
    // all the page nodes to ensure we are displaying the correct number
    _afterChange: function (e) {
        this.render();
    }

}, {
    ATTRS: {

        // Maximum number of items to display at one time
        display: {
            value: 10
        }
    }
});

Paginator View

Our Paginator View consists of four local controls: first, previous, next and last. In the middle of these four controls, we display a truncated list of pages (our Pages View previously discussed) from which the user can select.

FlickrSearch.Paginator = Y.Base.create('pager', Y.View, [], {

    // Our paginator will consist of controls as list elements so we need to
    // ensure our container is the proper parent element type
    containerTemplate: '<ul/>',

    // Our controls are list items we bind click events to. Based on the given
    // control type - stored in `data-type` - we will be able to determine the
    // course of action we need to take
    controlTemplate: '<li class="control {type}" data-type="{type}">' +
                        '{label}' +
                     '</li>',

    // We delegate click events for our controls
    events: {
        '.control': {
            'click': '_controlClick'
        }
    },

    // Our paginator will consist of a few controls as well as a list of pages
    // To get the list of pages, we need to instantiate a `PaginatorPages`
    // view. We also pass our model to the pages view to keep our controls and
    // page items in sync with each other
    initializer: function () {
        var model = this.get('model'),
            pages = this.pages = new FlickrSearch.PaginatorPages({
                model: model
            });

        model.after('change', this._afterModelChange, this);
    },

    // We can generate our controls as strings, but our pages controls are
    // generated through another module, so we need to append the pages
    // container node between our two sets of controls.
    render: function () {
        var container = this.get('container'),
            firstControls,
            lastControls;

        // build first and prev controls
        firstControls = this._makeControl('first');
        firstControls += this._makeControl('prev', 'Previous');

        // build next and last controls
        lastControls = this._makeControl('next');
        lastControls += this._makeControl('last');

        // add the controls and page items to the container
        container.append(firstControls);
        container.append(this.pages.render().get('container'));
        container.append(lastControls);

        return this;
    },

    // We use this method to simplify the building of our controls. This will
    // give us a Title Cased label from the `type` if we do not provide one
    _makeControl: function (type, label, control) {
        return Y.Lang.sub(this.controlTemplate, {
            type: type,
            label: label || (type.charAt(0).toUpperCase() + type.substr(1))
        });
    },

    // After our model is changed, we only need to update our controls
    _afterModelChange: function (e) {
        this._updateControls();
    },

    // We can know whether our controls should be disabled or not by checking
    // if there is a previous page (for first and previous controls) and a
    // next page (for the next and last controls)
    _updateControls: function () {
        var model = this.get('model'),
            hasPrev = model.hasPrevPage(),
            hasNext = model.hasNextPage(),
            disabled = 'disabled',
            container = this.get('container');

        container.one('.first').toggleClass(disabled, !hasPrev);
        container.one('.prev').toggleClass(disabled, !hasPrev);
        container.one('.next').toggleClass(disabled, !hasNext);
        container.one('.last').toggleClass(disabled, !hasNext);
    },

    // When one of our controls is clicked, we want to update the model as
    // expected, unless the control has been disabled.
    _controlClick: function (e) {
        e.preventDefault();

        var control = e.currentTarget.getData('type'),
            model = this.get('model');

        if (e.currentTarget.hasClass('disabled')) {
            return;
        }

        switch (control) {
            case 'first':
                model.set('page', 1);
                break;
            case 'prev':
                model.prevPage();
                break;
            case 'next':
                model.nextPage();
                break;
            case 'last':
                model.set('page', model.get('totalPages'));
                break;
            default:
                return;
        }
    },


    // ATTRS passthrough to our internal model for external gets and sets
    _setPageFn: function (val) {
        this.get('model').set('page', parseInt(val, 10));
        return val;
    },

    _getPageFn: function () {
        return this.get('model').get('page');
    },

    _setTotalItemsFn: function (val) {
        this.get('model').set('totalItems', parseInt(val, 10));
        return val;
    },

    _getTotalItemsFn: function () {
        return this.get('model').get('totalItems');
    }


}, {
    ATTRS: {
        // Relays changes to our model's page and retrives our model's page
        // when requested
        page: {
            setter: '_setPageFn',
            getter: '_getPageFn'
        },

        // Relays changes to our model's totalItems and retrieves our model's
        // totalItems when requested
        totalItems: {
            setter: '_setTotalItemsFn',
            getter: '_getTotalItemsFn'
        }
    }
});

That's all it takes for our Paginator. As such it's fairly flexible and extremely customizable if we need it to be.

Next we need to look at our actual pages that hold the images.

Page View

Our page view need only display the images we pass to it. We do this by sustituying placeholders with the data from the Flickr API.

FlickrSearch.PageView = Y.Base.create('page-view', Y.View, [], {

    // We will be displaying the images in list elements, so we need to
    // ensure the parent element is semantically correct
    containerTemplate: '<ul></ul>',

    // Our image template is dictated from the Flickr API. Here we set up
    // placeholders that will be substitued by the photo's API data in
    // order to display the correct photo
    imageTemplate: '<img src="http://farm{farm}.staticflickr.com/{server}/{id}_{secret}_q.jpg">',

    // In order to add photos to the container, they are expected to be in
    // the format from the API, this would mean an Array of photos.
    //
    // We loop through each of these photo data objects and generate an
    // image element. Then we append all the image elements to our
    // container
    addPhotos: function (photos) {
        var container = this.get('container'),
            photoItems = '',
            i,
            len;

        for (i = 0, len = photos.length; i < len; i++) {
            photoItems += Y.Lang.sub('<li>' + this.imageTemplate + '</li>', photos[i]);
        }

        this.get('container').append(photoItems)
    }

});

Search App View

Our application view is a bit more envolved. It needs to request images when the form is submitted and start a new series of pages. As well as fetch new images when a new page is requested.

FlickrSearch.App = Y.Base.create('search', Y.View, [], {

    // This is the Flickr API URL
    url: 'http://api.flickr.com/services/rest/?',

    // This is a flag that will determin if we are performing a new query
    // or simply going to a new page of the previous query
    _isNewQuery: false,

    // An array of pages that are being displayed. This will adjust as
    // the paginator is being interacted with, but should result in only
    // one item being persistent
    _pages: [],

    paginator: null,

    // When our form is submitted, we run a new query on it
    events: {
        'form .yui3-button': {
            'click': '_afterFormSubmit'
        }
    },

    // When we first initialize our application, we set up our paginator
    // We also listen to it's page change event to get that page's images
    initializer: function () {
        this._api = this.get('apiConfig');

        this.paginator = new FlickrSearch.Paginator({
            model:  new FlickrSearch.PaginatorModel({
                itemsPerPage: this._api.per_page
            })
        });

        this.paginator.get('model').after('pageChange', this._afterPageChange, this);
    },

    // We append our paginator to the paginator placeholder in our
    // container
    render: function () {
        this.get('container').one('.paginator').append(
            this.paginator.render().get('container')
        );
    },

    // We update the app based on our loading process
    //
    // If status is true, we add the "loading" class
    // If status is false, we remove the "loading" class
    setLoading: function (status) {
        this.get('container').toggleClass('loading', status);
    },

    // Our app needs a way to display a message to the user where there's
    // an error or remove an error message if there is no longer a
    // message that needs to be displayed
    setMessage: function (msg) {
        var container = this.get('container'),
            msgNode = container.one('.results .msg');

        if (msg) {
            container.one('.results').setHTML('<div class="msg">' + msg + '</div>');
            container.removeClass('hide-pg');
            this.setLoading(false);
        } else {
            if (msgNode) {
                msgNode.remove();
            }
        }
    },

    // When we request a new photo, there are a few things that happen.
    //
    // * First we alert our app that something is loading
    // * Then we construct our url based on the API configuration and the
    //   page requested
    // * Finally we request the API response through JSON-P
    //
    // If that request fails, we let the user know with a message
    // If the requst succeeds, we process the response data
    requestPhotos: function (page) {
        this.setLoading(true);

        var self = this,
            api = this._api,
            url = this.url;

        api.page = page || 1;

        url += Y.QueryString.stringify(api);

        Y.jsonp(url, {
            format: function (url, proxy) {
                return url + '&jsoncallback=' + proxy;
            },
            on: {
                failure: Y.bind(function () {
                    this.setLoading(false);
                    this.setMessage('Oops!! something broke :(');
                }, self),

                success: Y.bind(function (resp) {
                    this._processResults(resp.photos);
                    this._isNewQuery = false;
                }, self)
            }
        });
    },

    // When our form is submitted, we assume it's a new request. As a new
    // request we remove our old pages, ensure our paginator is set to
    // page 1 and request our new set of photos
    _afterFormSubmit: function (e) {
        e.preventDefault();

        while (this._pages.length) {
            this._pages.shift().destroy({ remove: true });
        }

        this._api.text = this.get('container').one('form input').get('value');

        this._isNewQuery = true;

        if (this.paginator.get('page') !== 1) {
            this.paginator.set('page', 1);
        } else {
            this.requestPhotos();
        }
    },

    // After we receive a response from the Flickr API, we check if we
    // have any pages to process and send the
    _processResults: function (resp) {
        this.setMessage( !resp.pages ?
            'There are no images for "' + this._api.text + '"' :
            ''
        );

        if (this._isNewQuery) {
            this.paginator.set('totalItems', parseInt(resp.total, 10));
        }

        this._createNewPage(resp.photo);
    },

    // Once our data have been received by the Flicker API and the
    // results have been processed, we create a new page containing the
    // new photos
    _createNewPage: function (photos) {
        var page = new FlickrSearch.PageView(),
            resultsNode = this.get('container').one('.results'),
            pageContainer;

        // Add our photos to the new page
        page.addPhotos(photos);

        pageContainer = page.get('container');

        // append our new page to the results node
        resultsNode.append(pageContainer);
        resultsNode.setStyle('height', pageContainer.get('offsetHeight'));


        // We do not want to display the new page before all the images
        // requested have had a chance to process.
        var images = pageContainer.all('img'),
            imagesLeft = images.size();

        images.after(['load', 'error', 'abort'], function (e) {

            if (!(--imagesLeft)) {

                // If there is more than one page, we have a previous page
                var prevPage = (this._pages.length > 1) ? this._pages.shift() : null;

                // Turn off the loading indicator
                this.setLoading(false);

                // Transition the new page in
                // If there is not a previous page, just display the new page
                pageContainer.transition({
                    opacity: 1,
                    duration: 1,
                    delay: (prevPage) ? 0.5 : 0
                });

                // Transition the previous page out
                // Once the transition is complete, remove the previous page
                if (prevPage) {
                    prevPage.get('container').transition({
                        opacity: 0,
                        duration: 1
                    }, function (e) {
                        prevPage.destroy({
                            remove: true
                        });
                    });
                }
            }
        }, this);

        // We now have a page to display, so let's show the paginator
        this.get('container').removeClass('hide-pg');
        this._pages.push(page);
    },

    // After our page changes, we request that page's photos
    _afterPageChange: function (e) {
        this.requestPhotos(e.newVal);
    }

}, {
    ATTRS: {
        // This will contain our settings for the Flickr API
        apiConfig: {
            value: {
                api_key: FLICKR_API_KEY,
                method: 'flickr.photos.search',
                safe_search: 1,
                sort: 'relevance',
                format: 'json',
                license: 4,
                per_page: 20
            }
        }
    }
});

Run with it!

Now we need to create an instance and render it

var flickrSearch = new FlickrSearch.App({
    container: '#demo'
});

flickrSearch.render();

Finishing touches

Whew, that was a lot to digest! One last element is our CSS.

<style scoped>
/** DEMO BOX **/
#demo {
    position: relative;
    border: 1px solid #cbcbcb;
    border-radius: 6px;
    padding: 8px;
    background: #fafafa;
}

/** SEARCH FORM **/
#demo form {
    border: 1px solid #cbcbcb;
    border-radius: 3px;
    padding: 8px;
    background: #fefefe;
}
#demo form input {
    font-size: 180%;
    padding: 3px 7px;
}

/** SEARCH RESULTS **/
#demo .results {
    position: relative;
    margin: 8px 0;
}
#demo.hide-pg .results {
    margin: 0;
}
#demo .results ul {
    margin: 0;
    padding: 0;
    text-align: center;
    position: absolute;
    opacity: 0;
}
#demo .results li {
    list-style: none;
    float: left;
    padding: 3px;
    position: relative;
    width: 150px;
    height: 150px;
}
#demo .results li img {
    background: #efefef;
    width: 150px;
    height: 150px;
}
#demo .results li:hover {
    z-index: 10;
}
#demo .results li:hover img {
    position: absolute;
    left: -5px;
    top: -5px;
    width: 160px;
    height: 160px;
    z-index: 10;
    border: 3px solid #fff;
    -webkit-box-shadow: 3px 3px 5px hsla(250, 40%, 30%, 0.5);
    -moz-box-shadow: 3px 3px 5px hsla(250, 40%, 30%, 0.5);
    box-shadow: 3px 3px 5px hsla(250, 40%, 30%, 0.5);
}

/** LOADING INDICATOR **/
#demo.loading .loading {
    display: block;
}
#demo .loading {
    background: url('../assets/paginator/images/loading.gif') left center repeat-x;
    height: 15px;
    width: 150px;
    border: 1px solid #3b4f93;
    border-radius: 4px;
    box-shadow: 3px 3px 3px hsla(40, 40%, 30%, 0.5);
    position: absolute;
    left: 50%;
    top: 50%;
    margin-left: -75px;
    display: none;
    z-index: 100;
    -webkit-box-shadow: 0 0 0 6px hsla(226, 10%, 40%, 0.4);
    -moz-box-shadow: 0 0 0 6px hsla(226, 10%, 40%, 0.4);
    box-shadow: 0 0 0 6px hsla(226, 10%, 40%, 0.4);
}

/** PAGINATOR CSS **/
#demo.hide-pg .paginator {
    display: none;
}

#demo .paginator {
    margin-top: 8px;
    text-align: center;
    border-top: 1px solid #eee;
    padding-top: 10px;
    margin: 0 10px;
}

#demo .paginator .control {
    display: block;
    padding: 0 .5em;
    text-align: center;
    text-decoration: none;
}
#demo .paginator ul {
    margin: 0;
    padding: 0;
    display: inline-block;
    zoom: 1; *display: inline;
}
#demo .paginator ul li {
    display: inline-block;
    zoom: 1; *display: inline;
    list-style: none;
}
#demo .paginator .control {
    border: solid 1px #CBCBCB;
    display: inline-block;
    zoom: 1; *display: inline;
    margin: 0 3px;
    padding: 0 .5em;
    text-align: center;
    text-decoration: none;
    line-height: 1.7em;
    color: #4A4A4A;
    font-family: arial,san-serif;
    background-color: #E6E6E6;
    -webkit-border-radius: 3px;
    -moz-border-radius: 3px;
    -ie-border-radius: 3px;
    -o-border-radius: 3px;
    border-radius: 3px;
    cursor: pointer;
}
#demo .paginator .control:hover {
    background-color: #d6d6d6;
    color: #3a3a3a;
}
#demo .paginator .control.selected,
#demo .paginator .control.selected:hover {
    cursor: default;
    font-weight: bold;
    font-weight: bold;
    background-color: #2647a0;
    color: #fff;
}
#demo .paginator .control.disabled,
#demo .paginator .control.disabled:hover {
    background-color: #E6E6E6;
    cursor: default;
    opacity: 0.55;
    filter: alpha(opacity=55);
}
#demo .paginator .page:hover {
    background-color: #bfdaff;
    color: #000;
}
</style>

The Whole Example

Now let's see it all together!

<style scoped>
/** DEMO BOX **/
#demo {
    position: relative;
    border: 1px solid #cbcbcb;
    border-radius: 6px;
    padding: 8px;
    background: #fafafa;
}

/** SEARCH FORM **/
#demo form {
    border: 1px solid #cbcbcb;
    border-radius: 3px;
    padding: 8px;
    background: #fefefe;
}
#demo form input {
    font-size: 180%;
    padding: 3px 7px;
}

/** SEARCH RESULTS **/
#demo .results {
    position: relative;
    margin: 8px 0;
}
#demo.hide-pg .results {
    margin: 0;
}
#demo .results ul {
    margin: 0;
    padding: 0;
    text-align: center;
    position: absolute;
    opacity: 0;
}
#demo .results li {
    list-style: none;
    float: left;
    padding: 3px;
    position: relative;
    width: 150px;
    height: 150px;
}
#demo .results li img {
    background: #efefef;
    width: 150px;
    height: 150px;
}
#demo .results li:hover {
    z-index: 10;
}
#demo .results li:hover img {
    position: absolute;
    left: -5px;
    top: -5px;
    width: 160px;
    height: 160px;
    z-index: 10;
    border: 3px solid #fff;
    -webkit-box-shadow: 3px 3px 5px hsla(250, 40%, 30%, 0.5);
    -moz-box-shadow: 3px 3px 5px hsla(250, 40%, 30%, 0.5);
    box-shadow: 3px 3px 5px hsla(250, 40%, 30%, 0.5);
}

/** LOADING INDICATOR **/
#demo.loading .loading {
    display: block;
}
#demo .loading {
    background: url('../assets/paginator/images/loading.gif') left center repeat-x;
    height: 15px;
    width: 150px;
    border: 1px solid #3b4f93;
    border-radius: 4px;
    box-shadow: 3px 3px 3px hsla(40, 40%, 30%, 0.5);
    position: absolute;
    left: 50%;
    top: 50%;
    margin-left: -75px;
    display: none;
    z-index: 100;
    -webkit-box-shadow: 0 0 0 6px hsla(226, 10%, 40%, 0.4);
    -moz-box-shadow: 0 0 0 6px hsla(226, 10%, 40%, 0.4);
    box-shadow: 0 0 0 6px hsla(226, 10%, 40%, 0.4);
}

/** PAGINATOR CSS **/
#demo.hide-pg .paginator {
    display: none;
}

#demo .paginator {
    margin-top: 8px;
    text-align: center;
    border-top: 1px solid #eee;
    padding-top: 10px;
    margin: 0 10px;
}

#demo .paginator .control {
    display: block;
    padding: 0 .5em;
    text-align: center;
    text-decoration: none;
}
#demo .paginator ul {
    margin: 0;
    padding: 0;
    display: inline-block;
    zoom: 1; *display: inline;
}
#demo .paginator ul li {
    display: inline-block;
    zoom: 1; *display: inline;
    list-style: none;
}
#demo .paginator .control {
    border: solid 1px #CBCBCB;
    display: inline-block;
    zoom: 1; *display: inline;
    margin: 0 3px;
    padding: 0 .5em;
    text-align: center;
    text-decoration: none;
    line-height: 1.7em;
    color: #4A4A4A;
    font-family: arial,san-serif;
    background-color: #E6E6E6;
    -webkit-border-radius: 3px;
    -moz-border-radius: 3px;
    -ie-border-radius: 3px;
    -o-border-radius: 3px;
    border-radius: 3px;
    cursor: pointer;
}
#demo .paginator .control:hover {
    background-color: #d6d6d6;
    color: #3a3a3a;
}
#demo .paginator .control.selected,
#demo .paginator .control.selected:hover {
    cursor: default;
    font-weight: bold;
    font-weight: bold;
    background-color: #2647a0;
    color: #fff;
}
#demo .paginator .control.disabled,
#demo .paginator .control.disabled:hover {
    background-color: #E6E6E6;
    cursor: default;
    opacity: 0.55;
    filter: alpha(opacity=55);
}
#demo .paginator .page:hover {
    background-color: #bfdaff;
    color: #000;
}
</style>
<div id="demo" class="yui3-skin-sam hide-pg">
    <form>
        <input type="text" name="q" value="kitten">
        <input type="submit" value="Search" class="yui3-button">
    </form>
    <div class="results"></div>
    <div class="paginator"></div>
    <div class="loading"></div>
</div>

<script>
YUI().use('paginator-core', 'model', 'view', 'transition', 'jsonp', 'querystring-stringify-simple', 'cssbutton', function (Y) {

    var FlickrSearch = {};

FlickrSearch.PaginatorModel = Y.Base.create('pager-model', Y.Model, [Y.Paginator.Core]);

FlickrSearch.PaginatorPages = Y.Base.create('pager-pages', Y.View, [], {

    // We modify the the containerTemplate so our page items are contained in
    // their proper parent
    containerTemplate: '<ul/>',

    // Our page numbers are in a list element (<li>) and contain data about
    // the page (`data-page`) and control type (`data-type`)
    pageTemplate: '<li class="control page page-{page}{selected}"' +
                  ' data-type="page" data-page="{page}">{page}</li>',

    // We delegate our .page element clicks
    events: {
        '.page': {
            'click': '_pageClick'
        }
    },

    // We listen to our model's `change` event to know what page controls to
    // display and which page item to activate
    initializer: function () {
        this.get('model').after('change', this._afterChange, this);
    },

    // When we render, we simply call _buildPageList to render the page items
    render: function () {
        this.get('container').setHTML(this._buildPageList());

        return this;
    },

    // Generates a string of page items based on the current page number and
    // the number of items we can display at one time. The current page of the
    // model will be given the "selected" class name.
    //
    // For example, if our current page is 20, we have a total of 50 pages
    // and we can only display 10, our paginator should display pages
    // 15 thru 24
    //
    // Given the same scenario, if we have a total of 20 pages, our paginator
    // should display 11 thru 20
    _buildPageList: function () {
        var data = this.get('model').toJSON(),
            pages = data.totalPages,
            pagesNode = '',
            display = this.get('display'),
            min = Math.max(1, data.page - Math.floor(display / 2)),
            max = min + display - 1;

        if (max > pages) {
            min -= max - pages;
        }

        min = Math.max(1, min);
        max = Math.min(pages, max);

        for (; min <= max; min++) {
            pagesNode += Y.Lang.sub(this.pageTemplate, {
                page: min,
                selected: (data.page === min) ? ' selected' : ''
            });
        }

        return pagesNode;
    },

    // When a page is clicked, we need to push that data to the model
    _pageClick: function (e) {
        var page = e.currentTarget.getData('page');

        this.get('model').set('page', parseInt(page, 10));
    },

    // When the model's `page` or `totalItems` changes, we need to re-render
    // all the page nodes to ensure we are displaying the correct number
    _afterChange: function (e) {
        this.render();
    }

}, {
    ATTRS: {

        // Maximum number of items to display at one time
        display: {
            value: 10
        }
    }
});

FlickrSearch.Paginator = Y.Base.create('pager', Y.View, [], {

    // Our paginator will consist of controls as list elements so we need to
    // ensure our container is the proper parent element type
    containerTemplate: '<ul/>',

    // Our controls are list items we bind click events to. Based on the given
    // control type - stored in `data-type` - we will be able to determine the
    // course of action we need to take
    controlTemplate: '<li class="control {type}" data-type="{type}">' +
                        '{label}' +
                     '</li>',

    // We delegate click events for our controls
    events: {
        '.control': {
            'click': '_controlClick'
        }
    },

    // Our paginator will consist of a few controls as well as a list of pages
    // To get the list of pages, we need to instantiate a `PaginatorPages`
    // view. We also pass our model to the pages view to keep our controls and
    // page items in sync with each other
    initializer: function () {
        var model = this.get('model'),
            pages = this.pages = new FlickrSearch.PaginatorPages({
                model: model
            });

        model.after('change', this._afterModelChange, this);
    },

    // We can generate our controls as strings, but our pages controls are
    // generated through another module, so we need to append the pages
    // container node between our two sets of controls.
    render: function () {
        var container = this.get('container'),
            firstControls,
            lastControls;

        // build first and prev controls
        firstControls = this._makeControl('first');
        firstControls += this._makeControl('prev', 'Previous');

        // build next and last controls
        lastControls = this._makeControl('next');
        lastControls += this._makeControl('last');

        // add the controls and page items to the container
        container.append(firstControls);
        container.append(this.pages.render().get('container'));
        container.append(lastControls);

        return this;
    },

    // We use this method to simplify the building of our controls. This will
    // give us a Title Cased label from the `type` if we do not provide one
    _makeControl: function (type, label, control) {
        return Y.Lang.sub(this.controlTemplate, {
            type: type,
            label: label || (type.charAt(0).toUpperCase() + type.substr(1))
        });
    },

    // After our model is changed, we only need to update our controls
    _afterModelChange: function (e) {
        this._updateControls();
    },

    // We can know whether our controls should be disabled or not by checking
    // if there is a previous page (for first and previous controls) and a
    // next page (for the next and last controls)
    _updateControls: function () {
        var model = this.get('model'),
            hasPrev = model.hasPrevPage(),
            hasNext = model.hasNextPage(),
            disabled = 'disabled',
            container = this.get('container');

        container.one('.first').toggleClass(disabled, !hasPrev);
        container.one('.prev').toggleClass(disabled, !hasPrev);
        container.one('.next').toggleClass(disabled, !hasNext);
        container.one('.last').toggleClass(disabled, !hasNext);
    },

    // When one of our controls is clicked, we want to update the model as
    // expected, unless the control has been disabled.
    _controlClick: function (e) {
        e.preventDefault();

        var control = e.currentTarget.getData('type'),
            model = this.get('model');

        if (e.currentTarget.hasClass('disabled')) {
            return;
        }

        switch (control) {
            case 'first':
                model.set('page', 1);
                break;
            case 'prev':
                model.prevPage();
                break;
            case 'next':
                model.nextPage();
                break;
            case 'last':
                model.set('page', model.get('totalPages'));
                break;
            default:
                return;
        }
    },


    // ATTRS passthrough to our internal model for external gets and sets
    _setPageFn: function (val) {
        this.get('model').set('page', parseInt(val, 10));
        return val;
    },

    _getPageFn: function () {
        return this.get('model').get('page');
    },

    _setTotalItemsFn: function (val) {
        this.get('model').set('totalItems', parseInt(val, 10));
        return val;
    },

    _getTotalItemsFn: function () {
        return this.get('model').get('totalItems');
    }


}, {
    ATTRS: {
        // Relays changes to our model's page and retrives our model's page
        // when requested
        page: {
            setter: '_setPageFn',
            getter: '_getPageFn'
        },

        // Relays changes to our model's totalItems and retrieves our model's
        // totalItems when requested
        totalItems: {
            setter: '_setTotalItemsFn',
            getter: '_getTotalItemsFn'
        }
    }
});


    FlickrSearch.PageView = Y.Base.create('page-view', Y.View, [], {

        // We will be displaying the images in list elements, so we need to
        // ensure the parent element is semantically correct
        containerTemplate: '<ul></ul>',

        // Our image template is dictated from the Flickr API. Here we set up
        // placeholders that will be substitued by the photo's API data in
        // order to display the correct photo
        imageTemplate: '<img src="http://farm{farm}.staticflickr.com/{server}/{id}_{secret}_q.jpg">',

        // In order to add photos to the container, they are expected to be in
        // the format from the API, this would mean an Array of photos.
        //
        // We loop through each of these photo data objects and generate an
        // image element. Then we append all the image elements to our
        // container
        addPhotos: function (photos) {
            var container = this.get('container'),
                photoItems = '',
                i,
                len;

            for (i = 0, len = photos.length; i < len; i++) {
                photoItems += Y.Lang.sub('<li>' + this.imageTemplate + '</li>', photos[i]);
            }

            this.get('container').append(photoItems)
        }

    });


    FlickrSearch.App = Y.Base.create('search', Y.View, [], {

        // This is the Flickr API URL
        url: 'http://api.flickr.com/services/rest/?',

        // This is a flag that will determin if we are performing a new query
        // or simply going to a new page of the previous query
        _isNewQuery: false,

        // An array of pages that are being displayed. This will adjust as
        // the paginator is being interacted with, but should result in only
        // one item being persistent
        _pages: [],

        paginator: null,

        // When our form is submitted, we run a new query on it
        events: {
            'form .yui3-button': {
                'click': '_afterFormSubmit'
            }
        },

        // When we first initialize our application, we set up our paginator
        // We also listen to it's page change event to get that page's images
        initializer: function () {
            this._api = this.get('apiConfig');

            this.paginator = new FlickrSearch.Paginator({
                model:  new FlickrSearch.PaginatorModel({
                    itemsPerPage: this._api.per_page
                })
            });

            this.paginator.get('model').after('pageChange', this._afterPageChange, this);
        },

        // We append our paginator to the paginator placeholder in our
        // container
        render: function () {
            this.get('container').one('.paginator').append(
                this.paginator.render().get('container')
            );
        },

        // We update the app based on our loading process
        //
        // If status is true, we add the "loading" class
        // If status is false, we remove the "loading" class
        setLoading: function (status) {
            this.get('container').toggleClass('loading', status);
        },

        // Our app needs a way to display a message to the user where there's
        // an error or remove an error message if there is no longer a
        // message that needs to be displayed
        setMessage: function (msg) {
            var container = this.get('container'),
                msgNode = container.one('.results .msg');

            if (msg) {
                container.one('.results').setHTML('<div class="msg">' + msg + '</div>');
                container.removeClass('hide-pg');
                this.setLoading(false);
            } else {
                if (msgNode) {
                    msgNode.remove();
                }
            }
        },

        // When we request a new photo, there are a few things that happen.
        //
        // * First we alert our app that something is loading
        // * Then we construct our url based on the API configuration and the
        //   page requested
        // * Finally we request the API response through JSON-P
        //
        // If that request fails, we let the user know with a message
        // If the requst succeeds, we process the response data
        requestPhotos: function (page) {
            this.setLoading(true);

            var self = this,
                api = this._api,
                url = this.url;

            api.page = page || 1;

            url += Y.QueryString.stringify(api);

            Y.jsonp(url, {
                format: function (url, proxy) {
                    return url + '&jsoncallback=' + proxy;
                },
                on: {
                    failure: Y.bind(function () {
                        this.setLoading(false);
                        this.setMessage('Oops!! something broke :(');
                    }, self),

                    success: Y.bind(function (resp) {
                        this._processResults(resp.photos);
                        this._isNewQuery = false;
                    }, self)
                }
            });
        },

        // When our form is submitted, we assume it's a new request. As a new
        // request we remove our old pages, ensure our paginator is set to
        // page 1 and request our new set of photos
        _afterFormSubmit: function (e) {
            e.preventDefault();

            while (this._pages.length) {
                this._pages.shift().destroy({ remove: true });
            }

            this._api.text = this.get('container').one('form input').get('value');

            this._isNewQuery = true;

            if (this.paginator.get('page') !== 1) {
                this.paginator.set('page', 1);
            } else {
                this.requestPhotos();
            }
        },

        // After we receive a response from the Flickr API, we check if we
        // have any pages to process and send the
        _processResults: function (resp) {
            this.setMessage( !resp.pages ?
                'There are no images for "' + this._api.text + '"' :
                ''
            );

            if (this._isNewQuery) {
                this.paginator.set('totalItems', parseInt(resp.total, 10));
            }

            this._createNewPage(resp.photo);
        },

        // Once our data have been received by the Flicker API and the
        // results have been processed, we create a new page containing the
        // new photos
        _createNewPage: function (photos) {
            var page = new FlickrSearch.PageView(),
                resultsNode = this.get('container').one('.results'),
                pageContainer;

            // Add our photos to the new page
            page.addPhotos(photos);

            pageContainer = page.get('container');

            // append our new page to the results node
            resultsNode.append(pageContainer);
            resultsNode.setStyle('height', pageContainer.get('offsetHeight'));


            // We do not want to display the new page before all the images
            // requested have had a chance to process.
            var images = pageContainer.all('img'),
                imagesLeft = images.size();

            images.after(['load', 'error', 'abort'], function (e) {

                if (!(--imagesLeft)) {

                    // If there is more than one page, we have a previous page
                    var prevPage = (this._pages.length > 1) ? this._pages.shift() : null;

                    // Turn off the loading indicator
                    this.setLoading(false);

                    // Transition the new page in
                    // If there is not a previous page, just display the new page
                    pageContainer.transition({
                        opacity: 1,
                        duration: 1,
                        delay: (prevPage) ? 0.5 : 0
                    });

                    // Transition the previous page out
                    // Once the transition is complete, remove the previous page
                    if (prevPage) {
                        prevPage.get('container').transition({
                            opacity: 0,
                            duration: 1
                        }, function (e) {
                            prevPage.destroy({
                                remove: true
                            });
                        });
                    }
                }
            }, this);

            // We now have a page to display, so let's show the paginator
            this.get('container').removeClass('hide-pg');
            this._pages.push(page);
        },

        // After our page changes, we request that page's photos
        _afterPageChange: function (e) {
            this.requestPhotos(e.newVal);
        }

    }, {
        ATTRS: {
            // This will contain our settings for the Flickr API
            apiConfig: {
                value: {
                    api_key: FLICKR_API_KEY,
                    method: 'flickr.photos.search',
                    safe_search: 1,
                    sort: 'relevance',
                    format: 'json',
                    license: 4,
                    per_page: 20
                }
            }
        }
    });

    var flickrSearch = new FlickrSearch.App({
        container: '#demo'
    });

    flickrSearch.render();

});
</script>