Thursday, June 26, 2014

Spring MVC - Thymeleaf - Bootstrap - Twitter Flight - II.2

Source Code | See Application
You can see the full description of the aplication and the other two entries related here

Initial configuration

The web.xml will be the same as the shown previously.

Spring Web Application Context - twitterFlightExample-servlet.xml

For default Spring search the webapplication file with the pattern: [servlet-name]-servlet.xml in this case the file will be: twitterFlightExample-servlet.xml
<beans>
<mvc:annotation-driven content-negotiation-manager="contentNegotiationManager"/>

<bean id="contentNegotiationManager" class="org.springframework.web.accept.ContentNegotiationManagerFactoryBean">
    <property name="favorPathExtension" value="false"/>
    <property name="favorParameter" value="true"/>
    <property name="mediaTypes">
        <value>
            json=application/json
        </value>
    </property>
</bean>

<import resource="classpath*:META-INF/spring/applicationContext*.xml"/>

<context:component-scan base-package="org.anotes.example.twitterflight.*"/>

</beans>

Notes

  • Thymeleaf is not needed because all will be rendered using js templates.
  • It is needed to use the contentNegotiationManager in order to set the favorPathExtension to false so the ajax call (request ...htm) can return json objects. (Note: favorPathExtension is true by default so we would have error code 406 as response)

Controller

We will use the following controller:
@Controller
public class Main2Controller {

    @Autowired
    ProductService productService;
    @Autowired
    MainInfo mainInfo;

    @RequestMapping("/main2")
    public ModelAndView main() {
        return new ModelAndView("main2");
    }

    @RequestMapping(value = "/changePage2", method = RequestMethod.POST)
    @ResponseBody
    public Main2Json changePage(@RequestParam("page") Integer page) {
        logger.info("Changing page to:{}", page);
        mainInfo.setCurrentPage(page);
        List<Product> products = productService.findProducts(mainInfo.getFilter(), mainInfo.getPageInfo());
        Main2Json main2Json = new Main2Json();
        main2Json.setProductList(products);
        main2Json.setPageInfo(mainInfo.getPageInfo());
        return main2Json;
    }

    @RequestMapping(value = "/changeFilter2", method = RequestMethod.POST)
    @ResponseBody
    public Main2Json changeFilter(@RequestParam("filter") String filter, @RequestParam("value") String value) {
        logger.info("Changing filter to:({}:{})", filter, value);
        mainInfo.updateFilter(filter, value);
        SummaryInfo summaryInfo = productService.createSummaryInfoFor(mainInfo.getFilter());
        mainInfo.setSummaryInfo(summaryInfo);
        List<Product> products = productService.findProducts(mainInfo.getFilter(), mainInfo.getPageInfo());
        Main2Json main2Json = new Main2Json();
        main2Json.setTotalPages(mainInfo.getPageInfo().getTotalPages());
        main2Json.setBrandSummaryList(summaryInfo.getBrandSummaryList());
        main2Json.setProductList(products);
        main2Json.setPageInfo(mainInfo.getPageInfo());
        main2Json.setFilterEntryList(mainInfo.getFilter().getFilterEntryList());
        return main2Json;
    }

Notes:

  • main() It renders the main page; in this case this page only contains html code so none model needs to be sent.
  • changePage(@RequestParam("page") Integer page) It changes the page to the value sent as parameter, get the products of that page and return a json with these values.
  • changeFilter(@RequestParam("filter") String filter, @RequestParam("value") String value) It adds the filter sent as parameter, regenerates the info base summary on the new filter; gets the products that comply with the new filter and returns a json with these values.

View

<html xmlns="http://www.w3.org/1999/xhtml"
      class="csstransforms csstransforms3d csstransitions">
<head>
    ...
    <link rel="stylesheet" href="/bower_components/twitterflightexample/twitterflighexample.css"/>
    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="/bower_components/bootstrap/css/bootstrap.min.css"/>
    <link rel="stylesheet" href="/bower_components/bootstrap/css/bootstrap-responsive.min.css"/>
    <link rel="stylesheet" href="/bower_components/nprogress/nprogress.css"/>
</head>
<body>
    ...
    <script data-main="app2/js/requireMain.js" src="/bower_components/requirejs/require.js"></script>
</body>

Notes:

  • It is a simple html page. Thymeleaf is not needed.
  • bootstrap-paginator: is used to implement the pagination
  • nprogress: is used to show the progress bar at the top during the execution of ajax
  • data-main="app2/js/requireMain.js"; is the main js file. It will be used Require.js (AMD) in order to manage the dependencies and components.

Twitter Flight Components

Define all components:

app.js
define([
    './data/data_provider',
    './ui/product_list',
    './ui/brand_list',
    './ui/breadcrumb',
    './ui/search_box',
    './ui/paginator'
], function (DataProviderData, ProductListUI, BrandListUI, BreadcrumbUI, SearchBoxUI, PaginatorUI) {
    var initialize = function () {
        DataProviderData.attachTo(document);
        ProductListUI.attachTo('#product-zone');
        BrandListUI.attachTo('#brand-zone');
        BreadcrumbUI.attachTo('#breadcrumb-zone');
        SearchBoxUI.attachTo('#search-zone');
        PaginatorUI.attachTo('#paginator-zone');
    };
    return {
        initialize: initialize
    };
});

Notes:

  • The data components normally are attached to document
  • The UI components are attached to the respective html zones.

Data Components

DataProviderData

define(
    ...
function (defineComponent) {
    return defineComponent(dataProvider);
    function dataProvider() {
        ...
        this.onUiPageDataChanged = function (ev, data) {
            console.info("Changing page to:" + data.page);
            var dataString = 'page=' + data.page;
            $.ajax({
                dataType: "json",
                type: "POST",
                url: "/changePage2.htm",
                data: dataString,
                success: function (data) {
                    console.info("ChangPage response:" + data);
                    $(document).trigger("pageDataChanged", {pageInfo: data.pageInfo, productList: data.productList})
                }
            });
        };

        this.onUiFilterChanged = function (ev, data) {
            console.info("Changing filter:" + data.filter + " value:" + data.value);
            var dataString = 'filter=' + data.filter + "&value=" + data.value;
            $.ajax({
                dataType: "json",
                type: "POST",
                url: "/changeFilter2.htm",
                data: dataString,
                success: function (data) {
                    console.info("ChangeFilter response. New number of pages:" + data.totalPages);
                    $(document).trigger("dataChanged", { resultSize: data.totalPages, pageInfo: data.pageInfo, productList: data.productList});
                    $(document).trigger("brandDataChanged", {brandList: data.brandSummaryList});
                    $(document).trigger("breadcrumbChanged", {filterList: data.filterEntryList});
                }
            });
        }

        this.after('initialize', function () {
            this.on('uiPageDataChanged', this.onUiPageDataChanged);
            this.on('uiFilterChanged', this.onUiFilterChanged);
        });
    }
}
Notes:
  • this.after('initialize', function () Registers listeners for the events: uiPageDataChanged,uiFilterChanged
  • this.onUiPageDataChanged = function (ev, data)
    • When the user changes the page, this method will execute an ajax call to: changePage2.htm in order to change the page on the server, this will return a json object.
    • Then it triggers the event pageDataChanged sending the json objects: pageInfo and productList
  • this.onUiFilterChanged = function (ev, data)
    • When the user changes any filter, this method will execute an ajax call to: changeFilter2.htm in order to change the filter on the server, this will return a json object.
    • Then it triggers the events dataChanged,brandDataChanged,breadcrumbChanged sending the json objects: pageInfo, productList,brandList,filterList

UI Components

SearchBoxUI

define(
    ...
function (defineComponent) {
    return defineComponent(searchBox);
    function searchBox() {
        this.defaultAttrs({
            txtSelector: '#txtSearch',
            btnSelector: '#btnSearch'
        });
        this.searchOnClick = function (e) {
            e.preventDefault();
            this.executeSearch();
        };

        this.executeSearch = function () {
            var $txtSearch = this.select('txtSelector');
            var textToSearch = $txtSearch.val().trim();
            if (!textToSearch) {
                return;
            }
            $txtSearch.val('');
            this.trigger("uiFilterChanged", {filter: "name", value: textToSearch})
        }

        this.after('initialize', function () {
            this.on('click', {
                btnSelector: this.searchOnClick
            });
            this.trigger("uiFilterChanged", {filter: "name", value: ""})
        });
    }
}
Notes:
  • this.after('initialize', function ()
    • Registers listeners for the native click event, on the button with id:btnSearch.
    • Trigger the event uiFilterChanged. This is needed in order to populate the initial page with all the products.
  • this.executeSearch = function () Gets the value that is set in the textfield and then triggers the event: uiFilterChanged

Templates

define(
function () {
    var brands =
        '{{#brandList}}\
         <div class="panel-collapse collapse in">\
         ...
        </div>\
        {{/brandList}}';

    var breadcrumb =
        '{{#filterList}}\
        <li>\
            <a href="#" data-filter-idx="{{filterIdx}}">\
            {{value}}\
            </a>\
        </li>\
        {{/filterList}}';
    var breadcrumbActive =
        '<li class="active">\
           <span>\
               {{value}}\
            </span>\
        </li>';

    var productsTitle = '&nbsp;&nbsp;&nbsp;&nbsp;Showing\
        <span>{{currentRangeStr}}</span> de\
        <span>{{productsNbr}}</span>';

    var products =
        '<div class="list-group">\
        {{#productList}}\
            <div class="list-group-item media-hover" data-product-gkey="{{gkey}}">\
            </div>\
            ...
        {{/productList}}\
        </div>';

    return {
        brands: brands,
        breadcrumb: breadcrumb,
        breadcrumbActive: breadcrumbActive,
        productsTitle: productsTitle,
        products: products
    }
}
Notes:
  • brands template used to generate the brand list
  • breadcrumb template used to generate the breadcrumb
  • breadcrumbActive template used to generate the active breadcrumb entry
  • productsTitle template used to generate products title
  • products template used to generate the list of products

BrandListUI

define(
    ...
function (defineComponent, Mustache, templates) {
    return defineComponent(brandList);
    function brandList() {
        this.defaultAttrs({
            divBrand: '#brand-list-zone'
        });

        this.onClick = function (ev, data) {
            ev.preventDefault();
            var $anchor = $(ev.target).closest('.list-group-item').find('a');
            var brandId = $anchor.attr("data-brand-id");
            this.trigger("uiFilterChanged", {filter: "brand", value: brandId})
        };

        this.renderItems = function (ev, data) {
            console.info("On Rendering Brands:" + data);
            var brandHtml = Mustache.render(templates.brands, data);
            var $brand = this.select('divBrand');
            $brand.html(brandHtml);
        };

        this.after('initialize', function () {
            this.on('click', this.onClick);
            this.on(document, 'brandDataChanged', this.renderItems);
        });
    }
}
Notes:
  • this.after('initialize', function () Registers listeners for the native click event, and the custom event brandDataChanged on document
  • this.onClick = function (ev, data) Gets the closest anchor to the html element where the click was done. The brandId is gotten and the event uiFilterChanged is triggered with the brandId as parameter
  • this.renderItems = function (ev, data) Gets the new component's html populating the template templates.brands and then replaces the component's html with this one

BreadcrumbUI

define(
    ...
function (defineComponent, Mustache, templates) {
    return defineComponent(breadcrumb);
    function breadcrumb() {
        ...    
        this.onClick = function (ev, data) {
            ev.preventDefault();
            var filterIdx = $(ev.target).attr("data-filter-idx");
            this.trigger("uiFilterChanged", {filter: "breadcrumbIdx", value: filterIdx})
        }

        this.renderItems = function (ev, data) {
            console.info("On Rendering Breadcrumb");
            var $breadcrumb = this.$node;
            var filterList = data.filterList;
            var last = filterList[filterList.length - 1];
            var breadcrumbActiveHtml = Mustache.render(templates.breadcrumbActive, last);
            var breadcrumbHtml = getBreadcrumbLinks(filterList);
            $breadcrumb.html(breadcrumbHtml + breadcrumbActiveHtml);
        }

        function getBreadcrumbLinks(fltList) {
            var breadcrumbHtml = "";
            if (fltList.length > 1) {
                var list = fltList.slice(0, fltList.length - 1);
                var filterIdx = 0;
                var filterList = {
                    filterList: list,
                    'filterIdx': function () {
                        return function (text, render) {
                            return filterIdx++;
                        }
                    }
                };
                breadcrumbHtml = Mustache.render(templates.breadcrumb, filterList);
            }
            return breadcrumbHtml;
        }

        this.after('initialize', function () {
            this.on('click', this.onClick);
            this.on(document, 'breadcrumbChanged', this.renderItems);
        });
    }
}
);
Notes:
  • this.after('initialize', function () Registers listeners for the native click event, and the custom event breadcrumbChanged on document
  • this.onClick = function (ev, data) Gets the data-filter-idx and triggers the event uiFilterChanged with the breadcrumbIdx as parameter
  • this.renderItems = function (ev, data) Gets the new component's html populating the templates: templates.breadcrumbActive, templates.breadcrumb and then replaces the component's html with this one

ProductListUI

define(
    ...
function (defineComponent, Mustache, templates) {
    return defineComponent(productList);
    function productList() {
        this.defaultAttrs({
            //selectors
            productListZone: "#product-list-zone",
            productListTitle: "#product-list-title"
        });
        this.onClick = function (ev) {
            var $productItem = $(ev.target).closest('.list-group-item');
            var productGkey = $productItem.attr("data-product-gkey");
            showProductInfo($productItem, productGkey);
        }

        function showProductInfo(obj, productGkey) {
            var $this = $(obj);
            var $collapse = $this.find('.collapse');
            var dataString = 'product=' + productGkey;
            var spanId = '#spn' + productGkey;
            var currentText = $(spanId).text();
            if (currentText == "Expand") {
                $(spanId).text("Collapse");
                $.ajax({
                    type: "GET",
                    url: "/getProductInfo.htm",
                    data: dataString,
                    success: function (data) {
                        $collapse.html(data);
                        $collapse.show();
                    }
                });
            } else {
                $(spanId).text("Expand")
                $collapse.hide();
            }
            return false;
        }

        this.renderItems = function (ev, data) {
            console.info("On Rendering Products");
            var $productListTitle = this.select('productListTitle');
            var currentRangePage = getCurrentRangePage(data.pageInfo);
            var pageInfoData = {currentRangeStr: currentRangePage, productsNbr: data.pageInfo.productsNbr}
            var newTitleHtml = Mustache.render(templates.productsTitle, pageInfoData);
            $productListTitle.html(newTitleHtml);
            var $productList = this.select('productListZone');
            var productListHtml = Mustache.render(templates.products, data);
            $productList.html(productListHtml);
        }

        function getCurrentRangePage(pageInfo) {
            var rangeEnd = (pageInfo.currentPage + 1) * pageInfo.pageSize;
            return (pageInfo.currentPage * pageInfo.pageSize + 1) + " - " + Math.min(rangeEnd, pageInfo.productsNbr);
        }

        this.after('initialize', function () {
            this.on(document, 'pageDataChanged', this.renderItems);
            this.on(document, 'dataChanged', this.renderItems);
            this.on('click', this.onClick);
        });
    }
}
Notes:
  • this.after('initialize', function () Registers listeners for the native click event, and the custom event dataChanged and pageDataChanged on document
  • this.onClick = function (ev, data) Gets the closest html element with class: list-group-item from it gets the productGkey and then calls the method showProductInfo
  • showProductInfo(obj, productGkey) Checks if the price history zone is expanded. If so, then it collapses and if not, then it calls the server with /getProductInfo1.htm and then expands and fills the zone of price history with the server response
  • this.renderItems = function (ev, data) Gets the new component's html populating the templates: templates.productsTitle, templates.products and then replaces the component's html with this one

PaginatorUI

define(
    ...
function (defineComponent) {
    return defineComponent(paginator);
    function paginator() {
        this.defaultAttrs({
            options: {
                bootstrapMajorVersion: 3,
                currentPage: 1,
                totalPages: 2,
                numberOfPages: 10
            }
        });

        this.onPageChanged = function (ev, lastPage, currentPage) {
            this.trigger('uiPageDataChanged', {page: (currentPage - 1)});
        };

        this.onDataChanged = function (ev, data) {
            var pagesNumber = data.resultSize;
            var $paginator = $('#paginator');
            if (pagesNumber > 1) {
                $paginator.show();
                this.attr.options.currentPage = 1;
                this.attr.options.totalPages = pagesNumber;
                $paginator.bootstrapPaginator(this.attr.options);
            } else {
                $paginator.hide();
            }
        };

        this.after('initialize', function () {
            this.on('page-changed', this.onPageChanged);
            this.on(document, 'dataChanged', this.onDataChanged);
        });
    }
}
Notes:
This component is a wrapper of bootstrap-paginator
  • this.after('initialize', function () Registers listeners for the bootstrap-paginator's page-changed event, and the custom event dataChanged on document
  • this.onPageChanged = function (ev, lastPage, currentPage) Triggers the uiPageDataChanged event with the page (-1) sent by bootstrap-paginator
  • this.onDataChanged = function (ev, data) Gets the new pages number and based on this the paginator is hidden or shown with the new pages number.

0 comments:

Post a Comment