├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── favicon.ico ├── gh-pages-patch.diff ├── karma.config.js ├── package-lock.json ├── package.json ├── protractor.config.js ├── publish-to-gh-pages.sh ├── source ├── app │ ├── components │ │ ├── _common │ │ │ ├── config.js │ │ │ ├── directives │ │ │ │ ├── datepicker-init.directive.js │ │ │ │ ├── datepicker-init.directive.spec.js │ │ │ │ ├── dropdown-init.directive.js │ │ │ │ ├── dropdown-init.directive.spec.js │ │ │ │ ├── focus-me.directive.js │ │ │ │ ├── focus-me.directive.spec.js │ │ │ │ ├── loading-button.directive.js │ │ │ │ ├── loading-button.directive.spec.js │ │ │ │ ├── select-init.directive.js │ │ │ │ ├── select-init.directive.spec.js │ │ │ │ ├── tooltip-init.directive.js │ │ │ │ ├── tooltip-init.directive.spec.js │ │ │ │ ├── validate-number.directive.js │ │ │ │ └── validate-number.directive.spec.js │ │ │ ├── index.js │ │ │ ├── production │ │ │ │ ├── exception-handler.decorator.js │ │ │ │ ├── exception-handler.decorator.spec.js │ │ │ │ └── production.config.js │ │ │ ├── run.js │ │ │ ├── services │ │ │ │ ├── ajax-error-handler.service.js │ │ │ │ ├── ajax-error-handler.service.spec.js │ │ │ │ ├── error.service.js │ │ │ │ ├── error.service.spec.js │ │ │ │ ├── event.constant.js │ │ │ │ ├── event.constant.spec.js │ │ │ │ ├── logger.service.js │ │ │ │ ├── logger.service.spec.js │ │ │ │ ├── resolve.service.js │ │ │ │ ├── resolve.service.spec.js │ │ │ │ ├── router-helper.provider.js │ │ │ │ ├── router-helper.provider.spec.js │ │ │ │ ├── user.service.js │ │ │ │ └── user.service.spec.js │ │ │ └── styles │ │ │ │ ├── animation.styl │ │ │ │ ├── common.styl │ │ │ │ ├── fonts.styl │ │ │ │ ├── helper.styl │ │ │ │ ├── mixin.styl │ │ │ │ ├── responsive.styl │ │ │ │ └── variable.styl │ │ ├── _layout │ │ │ ├── bg.png │ │ │ ├── index.js │ │ │ ├── layout.route.js │ │ │ ├── layout.styl │ │ │ ├── logo.png │ │ │ └── main.layout.jade │ │ ├── banner │ │ │ ├── banner.directive.js │ │ │ ├── banner.directive.spec.js │ │ │ ├── banner.jade │ │ │ └── index.js │ │ ├── breadcrumb │ │ │ ├── breadcrumb.controller.js │ │ │ ├── breadcrumb.controller.spec.js │ │ │ ├── breadcrumb.jade │ │ │ ├── breadcrumb.styl │ │ │ └── index.js │ │ ├── footer │ │ │ ├── footer.controller.js │ │ │ ├── footer.controller.spec.js │ │ │ ├── footer.jade │ │ │ ├── footer.styl │ │ │ └── index.js │ │ ├── header │ │ │ ├── header.controller.js │ │ │ ├── header.controller.spec.js │ │ │ ├── header.jade │ │ │ ├── header.styl │ │ │ └── index.js │ │ ├── home-hero │ │ │ ├── home-hero.directive.js │ │ │ ├── home-hero.directive.spec.js │ │ │ ├── home-hero.jade │ │ │ ├── home-hero.styl │ │ │ └── index.js │ │ ├── loading │ │ │ ├── config.js │ │ │ ├── index.js │ │ │ └── loading.styl │ │ ├── login-form │ │ │ ├── index.js │ │ │ ├── login-form.controller.js │ │ │ ├── login-form.controller.spec.js │ │ │ ├── login-form.directive.js │ │ │ ├── login-form.jade │ │ │ └── login-form.styl │ │ ├── modal │ │ │ ├── index.js │ │ │ ├── modal.service.js │ │ │ ├── modal.service.spec.js │ │ │ └── modal.styl │ │ ├── phone-form │ │ │ ├── index.js │ │ │ ├── phone-form.controller.js │ │ │ ├── phone-form.controller.spec.js │ │ │ ├── phone-form.directive.js │ │ │ ├── phone-form.jade │ │ │ └── phone-form.styl │ │ ├── phone-table │ │ │ ├── index.js │ │ │ ├── phone-table.controller.js │ │ │ ├── phone-table.directive.js │ │ │ ├── phone-table.directive.spec.js │ │ │ ├── phone-table.jade │ │ │ └── phone-table.styl │ │ ├── sidebar-sm │ │ │ ├── index.js │ │ │ ├── sidebar-sm.controller.js │ │ │ ├── sidebar-sm.controller.spec.js │ │ │ ├── sidebar-sm.jade │ │ │ └── sidebar-sm.styl │ │ ├── sidebar │ │ │ ├── index.js │ │ │ ├── sidebar.controller.js │ │ │ ├── sidebar.controller.spec.js │ │ │ ├── sidebar.jade │ │ │ └── sidebar.styl │ │ └── square-menu │ │ │ ├── index.js │ │ │ ├── square-menu.directive.js │ │ │ ├── square-menu.directive.spec.js │ │ │ ├── square-menu.jade │ │ │ └── square-menu.styl │ ├── index.jade │ ├── index.js │ └── pages │ │ ├── 404 │ │ ├── 404.jade │ │ ├── 404.route.js │ │ └── index.js │ │ ├── dashboard │ │ ├── dashboard.controller.js │ │ ├── dashboard.controller.spec.js │ │ ├── dashboard.jade │ │ ├── dashboard.route.js │ │ ├── dashboard.styl │ │ └── index.js │ │ ├── home │ │ ├── home.jade │ │ ├── home.route.js │ │ ├── home.styl │ │ └── index.js │ │ ├── login │ │ ├── index.js │ │ ├── login.controller.js │ │ ├── login.controller.spec.js │ │ ├── login.jade │ │ ├── login.route.js │ │ └── login.styl │ │ └── phone │ │ ├── add │ │ ├── phone-add.controller.js │ │ ├── phone-add.controller.spec.js │ │ ├── phone-add.jade │ │ └── phone-add.styl │ │ ├── detail │ │ ├── phone-detail.controller.js │ │ ├── phone-detail.controller.spec.js │ │ ├── phone-detail.jade │ │ └── phone-detail.styl │ │ ├── index.js │ │ ├── phone.controller.js │ │ ├── phone.controller.spec.js │ │ ├── phone.jade │ │ ├── phone.route.js │ │ ├── phone.service.js │ │ └── phone.service.spec.js └── test │ ├── e2e │ ├── helper.js │ ├── mocks │ │ ├── e2e.config.js │ │ ├── e2e.data.js │ │ ├── e2e.phone.js │ │ ├── e2e.user.js │ │ └── index.js │ └── specs │ │ ├── 404.spec.js │ │ ├── dashboard.spec.js │ │ ├── home.spec.js │ │ ├── login.spec.js │ │ ├── page-objects │ │ ├── 404.page.js │ │ ├── dashboard.page.js │ │ ├── home.page.js │ │ ├── login.page.js │ │ ├── phone-add.page.js │ │ ├── phone-detail.page.js │ │ ├── phone-form.comp.js │ │ └── phone-main.page.js │ │ ├── phone-add.spec.js │ │ ├── phone-detail.spec.js │ │ └── phone-main.spec.js │ └── unit │ └── helper.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [package.json] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | webpack.config.js 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # common 2 | .DS_Store 3 | # node dependencies 4 | node_modules 5 | npm-debug.log 6 | 7 | # build file 8 | build 9 | 10 | # test folder 11 | source/test/e2e/screenshots/ 12 | source/test/unit/results/ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | only: 3 | - master 4 | language: node_js 5 | node_js: 6 | - stable 7 | script: 8 | - ./publish-to-gh-pages.sh 9 | after_success: 10 | - npm run codecov 11 | - npm run server 12 | - npm run e2e -- --ci --build-id=$TRAVIS_BUILD_NUMBER --job-id=$TRAVIS_JOB_NUMBER 13 | env: 14 | global: 15 | - GitHub_REF: github.com/PinkyJie/angular1-webpack-starter.git 16 | - secure: "Sq6NJGdO4trRXiY+pr8RMV8GuZjsML8If6653ZKVqsHPFPS8eTEKNlIepXcWf/+I6cqVhiT0B/IUxVwdbw/Jh+0BLM1RxofvyFAcZAQaxhRnfDnVdGqysP89RlxuoR/Bc58F7qFwu4nfJ96dFfiIDEnV5whLbvekXperPeLDy41BbgRov4H2ifz3OiwAK2DISmM4ikJBCkwKOA0BHo9/cDtobHNicCM7sE36aYIjYXK6esM8aBZ/XHiLzF/rlfRGQFvNmaxq6Wu41GbtnatDg3PUqBIxCiutKpqEpPothGN/gbTZFNQBCJGmNqnp8KxD2ua3mBvmnL0x+Rij27uMFTfSqfDyLTx/A9M1tMYaIbwBNXz3tR7jyQk0WyAFbuTyw4i6rtM4qqRLSbkF3DXImM5qPqO5zlXNMSAAlOqDXWL9+PpU2U0S7YtxZiV5fzGuZ77F1XTsnHhTgNDru0l0mZhPzTfb+rnagcT74O/6XBeShEZ+eh9gfDLF0/uyXP4Jjif/7204M+Attk/GUuXht+A3+SttUJ/nGbvNJyUi1OXZonuRJSkEPvNu/88KyMvLP/QxTWo0UVE+2Vxb7p9MQo5Fl/W0cCTLQorJX/DYVkJErnktzHX0UV4Y0dR0pQKZpUcmkxkk2zQ/uLugVRD3oHw/Hnzbb3PWlPL5vosIsoE=" 17 | - CXX: g++-4.8 18 | addons: 19 | sauce_connect: 20 | username: "sd4399340" 21 | access_key: "5829a37c-41c0-4490-b6c2-061ae4acc5e9" 22 | apt: 23 | sources: 24 | - ubuntu-toolchain-r-test 25 | packages: 26 | - gcc-4.8 27 | - g++-4.8 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 马斯特 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # angular1-webpack-starter 2 | [![Travis](https://img.shields.io/travis/PinkyJie/angular1-webpack-starter.svg?style=flat-square)](https://travis-ci.org/PinkyJie/angular1-webpack-starter) 3 | [![Codecov](https://img.shields.io/codecov/c/github/PinkyJie/angular1-webpack-starter.svg?style=flat-square)](https://codecov.io/github/PinkyJie/angular1-webpack-starter) 4 | [![David](https://img.shields.io/david/PinkyJie/angular1-webpack-starter.svg?style=flat-square)](https://david-dm.org/pinkyjie/angular1-webpack-starter#info=dependencies&view=table) 5 | [![David](https://img.shields.io/david/dev/PinkyJie/angular1-webpack-starter.svg?style=flat-square)](https://david-dm.org/pinkyjie/angular1-webpack-starter#info=devDependencies&view=table) 6 | [![node](https://img.shields.io/node/v/angular1-webpack-starter.svg?style=flat-square)](https://nodejs.org) 7 | [![npm](https://img.shields.io/npm/v/angular1-webpack-starter.svg?style=flat-square)](https://www.npmjs.com/package/angular1-webpack-starter) 8 | 9 | A starter project using Angular 1.x with Webpack. A Webpack + ES6 re-implementation of the [generator-aio-angular](https://github.com/PinkyJie/generator-aio-angular) project. 10 | 11 | Still wanna use **Gulp + ES5**? Check the [generator-aio-angular](https://github.com/PinkyJie/generator-aio-angular) project. 12 | 13 | > Pure front-end implementation, all API interaction are mocked using [angular-mocks](https://docs.angularjs.org/api/ngMock). 14 | 15 | ## Preview 16 | 17 | Check out the [demo site](http://pinkyjie.com/angular1-webpack-starter/#/). 18 | 19 | > The dome site is a pure front-end implementation, so you can use any email/password to login, see [mock file](source/test/e2e/mocks/e2e.user.js) for detail. It is hosted on Github pages, no back-end support, so we use `#` style URL. 20 | 21 | ## Features 22 | 23 | * ES6 24 | * Component based structure proposed in https://github.com/fouber/blog/issues/10 25 | * Lazy load resources(js/css/images/templates...) for each page 26 | * Material Design using [MaterializeCSS](http://materializecss.com/) 27 | * Flex Layout 28 | * Responsive Design 29 | * Support multiple devices with different screen size. 30 | * Easy responsive implementation, very convenient to support small screen devices. (see [responsive.styl](source/app/components/_common/styles/responsive.styl)) 31 | * Animation 32 | * Using [animate.css](https://daneden.github.io/animate.css/). 33 | * All the animation defined by `animate.css` can be used directly as keyframe animation. (see [animation.styl](source/app/components/_common/styles/animation.styl)) 34 | * More understandable router design 35 | * Easy implementation for Sidebar Navigation and Breadcrumb 36 | 37 | ## Get Started 38 | 39 | ```bash 40 | git clone https://github.com/PinkyJie/angular1-webpack-starter.git 41 | cd angular1-webpack-starter 42 | npm install 43 | npm start 44 | ``` 45 | 46 | Then open your browser with URL `http://localhost:8080/webpack-dev-server/`. 47 | 48 | ## Tests 49 | 50 | * Unit Test: `npm test` 51 | * Unit Test with auto watch: `npm run test:watch` 52 | * E2E Test: `npm run e2e` 53 | * run `npm run webdriver-update` first 54 | * make sure a local mock server is running 55 | 56 | Check the [Unit test coverage report](http://pinkyjie.com/angular1-webpack-starter/coverage). 57 | 58 | Check the E2E test report: [![Sauce Test Status](https://saucelabs.com/buildstatus/sd4399340)](https://saucelabs.com/u/sd4399340) 59 | 60 | [![Sauce Test Status](https://saucelabs.com/browser-matrix/sd4399340.svg)](https://saucelabs.com/u/sd4399340) 61 | 62 | 63 | ## Building 64 | 65 | ```bash 66 | npm run build 67 | ``` 68 | The optimized files will be generated in `build` folder. 69 | 70 | ## CI 71 | Proudly use [Travis](https://travis-ci.org/) to do Continuous Integration. 72 | 73 | Every push will trigger a build on Travis, it will automatically: 74 | - run unit test. 75 | - run build script, deploy website and test coverage report to Github pages. 76 | - run E2E test on different browsers using [Sauce Labs](https://saucelabs.com). 77 | 78 | Check [.travis.yml](.travis.yml) and [publish-to-gh-pages.sh](publish-to-gh-pages.sh) for detail implementation. 79 | 80 | Check [Travis build log](https://travis-ci.org/PinkyJie/angular1-webpack-starter) for build results. 81 | 82 | ## Blog series 83 | 84 | http://pinkyjie.com/tags/angular1-webpack-starter/ 85 | 86 | ## License 87 | 88 | MIT 89 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkyJie/angular1-webpack-starter/6e7b0f6fd04a94df996dfeecc447f38b167a5196/favicon.ico -------------------------------------------------------------------------------- /gh-pages-patch.diff: -------------------------------------------------------------------------------- 1 | diff --git a/source/app/components/_common/services/router-helper.provider.js b/source/app/components/_common/services/router-helper.provider.js 2 | index 426cf72..5319d32 100644 3 | --- a/source/app/components/_common/services/router-helper.provider.js 4 | +++ b/source/app/components/_common/services/router-helper.provider.js 5 | @@ -101,7 +101,7 @@ class RouterHelperProvider { 6 | mainTitle: '', 7 | resolveAlways: {} 8 | }; 9 | - this.$locationProvider.html5Mode(true); 10 | + this.$locationProvider.html5Mode(false); 11 | } 12 | 13 | configure (cfg) { 14 | diff --git a/source/app/index.jade b/source/app/index.jade 15 | index edbe21b..f16f9ab 100644 16 | --- a/source/app/index.jade 17 | +++ b/source/app/index.jade 18 | @@ -10,7 +10,7 @@ html(lang="en", ng-app= app, ng-strict-di) 19 | meta(charset="utf-8") 20 | meta(http-equiv="X-UA-Compatible", content="IE=edge, chrome=1") 21 | meta(name="viewport", content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no") 22 | - base(href="/") 23 | + base(href="./") 24 | //- babel polyfill for IE 25 | //- conditional comment is not supported from IE 10 26 | //- TODO: how to include this only for IE 27 | -------------------------------------------------------------------------------- /karma.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const args = require('yargs').argv; 4 | 5 | const unitTestEntry = 'source/test/unit/helper.js'; 6 | // run multiple times in watch mode 7 | const singleRun = !args.watch; 8 | // use phantomjs in watch mode 9 | const browser = (args.watch || args.ci) ? 'PhantomJS' : 'Chrome'; 10 | // load babel polyfill for phantomjs 11 | const files = browser === 'PhantomJS' ? [ 12 | 'node_modules/babel-core/browser-polyfill.js', 13 | unitTestEntry 14 | ] : [ 15 | unitTestEntry 16 | ]; 17 | 18 | const include = [ 19 | path.resolve('./source') 20 | ]; 21 | 22 | const preLoaders = [ 23 | // Process test code with Babel 24 | {test: /\.spec\.js$/, loader: 'babel', include}, 25 | // Process all non-test code with Isparta 26 | {test: /\.js$/, loader: 'isparta', include, exclude: /\.spec\.js$/} 27 | ]; 28 | const loaders = [ 29 | {test: /\.styl$/, loader: 'style!css!stylus'}, 30 | {test: /\.jade$/, loader: 'jade'}, 31 | {test: /\.(png|jpg)$/, loader: 'null'} 32 | ]; 33 | const processors = {}; 34 | processors[unitTestEntry] = ['webpack', 'sourcemap']; 35 | processors['source/app/**/*.js'] = ['webpack', 'sourcemap']; 36 | 37 | // for watch mode, only show text coverage 38 | const reporters = args.watch ? [ 39 | 'mocha', 'coverage' 40 | ] : [ 41 | 'mocha', 'coverage', 'junit' 42 | ]; 43 | const coverageReporters = args.watch ? [ 44 | {type: 'text-summary'} 45 | ] : [ 46 | {type: 'lcov', subdir: '.'}, 47 | {type: 'text-summary'} 48 | ]; 49 | 50 | module.exports = (config) => { 51 | config.set({ 52 | basePath: '.', 53 | frameworks: ['jasmine'], 54 | exclude: [], 55 | files, 56 | webpack: { 57 | devtool: 'inline-source-map', 58 | module: { 59 | preLoaders, 60 | loaders 61 | }, 62 | cache: true, 63 | plugins: [ 64 | new webpack.ProvidePlugin({ 65 | $: 'jquery', 66 | jQuery: 'jquery', 67 | 'window.jQuery': 'jquery' 68 | }) 69 | ] 70 | }, 71 | webpackMiddleware: { 72 | stats: { 73 | chunkModules: false, 74 | colors: true 75 | } 76 | }, 77 | preprocessors: processors, 78 | reporters, 79 | coverageReporter: { 80 | dir: 'source/test/unit/results/coverage', 81 | reporters: coverageReporters 82 | }, 83 | junitReporter: { 84 | outputDir: 'source/test/unit/results/junit' 85 | }, 86 | reportSlowerThan: 500, 87 | singleRun, 88 | browsers: [ 89 | browser 90 | ] 91 | }); 92 | }; 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular1-webpack-starter", 3 | "version": "0.5.0", 4 | "description": "Component based Angular web development with Webpack.", 5 | "engines": { 6 | "node": ">=4.0.0" 7 | }, 8 | "repository": "PinkyJie/angular1-webpack-starter", 9 | "license": "MIT", 10 | "main": "source/app/index.js", 11 | "scripts": { 12 | "start": "webpack-dev-server --mock", 13 | "build": "rm -rf ./build && webpack --progress --mock --prod", 14 | "test": "rm -rf ./source/test/unit/results && karma start ./karma.config.js", 15 | "codecov": "cat ./source/test/unit/results/coverage/lcov.info | codecov", 16 | "test:watch": "karma start ./karma.config.js --watch", 17 | "webdriver-update": "webdriver-manager update", 18 | "webdriver-start": "webdriver-manager start", 19 | "e2e": "protractor ./protractor.config.js", 20 | "server": "nohup http-server ./build &" 21 | }, 22 | "author": { 23 | "name": "马斯特", 24 | "email": "pilixiaoxuanfeng@gmail.com", 25 | "url": "https://github.com/PinkyJie" 26 | }, 27 | "files": [ 28 | "source" 29 | ], 30 | "keywords": [ 31 | "es6", 32 | "components", 33 | "angular", 34 | "webpack", 35 | "jade", 36 | "stylus", 37 | "ui-router", 38 | "material design" 39 | ], 40 | "devDependencies": { 41 | "angular-mocks": "~1.4.5", 42 | "autoprefixer": "^6.3.3", 43 | "babel-core": "^5.8.24", 44 | "babel-loader": "^5.3.2", 45 | "codecov.io": "^0.1.6", 46 | "copy-webpack-plugin": "^0.3.3", 47 | "css-loader": "^0.17.0", 48 | "eslint": "^1.7.1", 49 | "eslint-loader": "^1.0.0", 50 | "extract-text-webpack-plugin": "^0.9.1", 51 | "file-loader": "^0.8.4", 52 | "html-webpack-plugin": "^2.9.0", 53 | "http-server": "^0.8.5", 54 | "isparta-loader": "^1.0.0", 55 | "jade": "^1.11.0", 56 | "jade-loader": "^0.7.1", 57 | "jasmine-core": "^2.3.4", 58 | "jasmine-spec-reporter": "^2.4.0", 59 | "karma": "^0.13.10", 60 | "karma-chrome-launcher": "^0.1.8", 61 | "karma-cli": "^0.1.1", 62 | "karma-coverage": "^0.3.1", 63 | "karma-jasmine": "^0.3.5", 64 | "karma-junit-reporter": "^0.3.6", 65 | "karma-mocha-reporter": "^1.0.2", 66 | "karma-phantomjs-launcher": "^0.2.1", 67 | "karma-sourcemap-loader": "^0.3.5", 68 | "karma-webpack": "^1.7.0", 69 | "null-loader": "^0.1.1", 70 | "oclazyload": "^1.0.8", 71 | "phantomjs": "^1.9.16", 72 | "postcss-loader": "^0.8.1", 73 | "protractor": "^5.1.2", 74 | "protractor-jasmine2-screenshot-reporter": "^0.1.7", 75 | "raw-loader": "^0.5.1", 76 | "style-loader": "^0.18.2", 77 | "stylus": "^0.54.5", 78 | "stylus-loader": "^3.0.1", 79 | "url-loader": "^0.5.6", 80 | "webpack": "^1.12.2", 81 | "webpack-dev-server": "^1.10.1", 82 | "yargs": "^3.25.0" 83 | }, 84 | "dependencies": { 85 | "angular": "~1.4.5", 86 | "angular-animate": "~1.4.5", 87 | "angular-loading-bar": "^0.8.0", 88 | "angular-messages": "~1.4.5", 89 | "angular-ui-router": "^0.2.15", 90 | "animate.css": "^3.4.0", 91 | "hammerjs": "^2.0.4", 92 | "jquery": "~2.1.4", 93 | "materialize-css": "0.97.1" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /publish-to-gh-pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ev 3 | 4 | # run unit test (--ci: use phantomjs browser) 5 | npm test -- --ci 6 | 7 | # apply /#/ style router for SPA 8 | git apply ./gh-pages-patch.diff 9 | 10 | # run build 11 | npm run build 12 | 13 | # copy unit test coverage report to build folder 14 | cp -r ./source/test/unit/results/coverage/lcov-report ./build/coverage 15 | # rename _common to common cause github pages dose not support this 16 | mv ./build/coverage/app/components/_common ./build/coverage/app/components/common 17 | sed -i 's/href=\"app\/components\/_common/href=\"app\/components\/common/g' ./build/coverage/index.html 18 | 19 | # push build folder to github 20 | cd build 21 | # git init 22 | git init 23 | # inside this git repo we'll pretend to be a new user 24 | git config user.name "Travis CI" 25 | git config user.email "travis@pinkyjie.com" 26 | 27 | # The first and only commit to this new Git repo contains all the 28 | # files present with the commit message "Deploy to GitHub Pages". 29 | git add . 30 | git commit -m "Deploy at `date +"%Y-%m-%d %H:%M"`" 31 | 32 | # Force push from the current repo's master branch to the remote 33 | # repo's gh-pages branch. (All previous history on the gh-pages branch 34 | # will be lost, since we are overwriting it.) We redirect any output to 35 | # /dev/null to hide any sensitive credential data that might otherwise be exposed. 36 | git push --force --quiet "https://${GitHub_TOKEN}@${GitHub_REF}" master:gh-pages > /dev/null 2>&1 37 | 38 | -------------------------------------------------------------------------------- /source/app/components/_common/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | appTitle: 'Aio Angular App' 3 | }; 4 | 5 | appConfig.$inject = ['RouterHelperProvider']; 6 | function appConfig (RouterHelperProvider) { 7 | RouterHelperProvider.configure({mainTitle: config.appTitle}); 8 | } 9 | 10 | export default appConfig; 11 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/datepicker-init.directive.js: -------------------------------------------------------------------------------- 1 | function DatepickerInitDirective () { 2 | return { 3 | require: 'ngModel', 4 | restrict: 'A', 5 | link 6 | }; 7 | 8 | function link (scope, element, attrs, ngModelCtrl) { 9 | const pickadate = element.pickadate({ 10 | format: 'yyyy-m-d' 11 | }); 12 | const watcher = scope.$watch(attrs.ngModel, (value) => { 13 | // set initial model value to date picker, only once 14 | if (value) { 15 | const picker = pickadate.pickadate('picker'); 16 | picker.set('select', value); 17 | // cancel the watcher 18 | watcher(); 19 | } 20 | }); 21 | ngModelCtrl.$formatters.unshift((modelValue) => { 22 | if (modelValue) { 23 | const date = new Date(modelValue); 24 | return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; 25 | } 26 | }); 27 | } 28 | } 29 | 30 | DatepickerInitDirective.$inject = []; 31 | 32 | export default DatepickerInitDirective; 33 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/datepicker-init.directive.spec.js: -------------------------------------------------------------------------------- 1 | import DatepickerInitDirective from './datepicker-init.directive'; 2 | 3 | describe('DatepickerInit Directive', () => { 4 | let scope; 5 | let element; 6 | let $compile; 7 | 8 | beforeEach(() => { 9 | angular.module('test', []) 10 | .directive('aioDatepickerInit', DatepickerInitDirective); 11 | angular.mock.module('test'); 12 | }); 13 | 14 | beforeEach(() => { 15 | angular.mock.inject(($rootScope, _$compile_) => { 16 | scope = $rootScope.$new(); 17 | $compile = _$compile_; 18 | }); 19 | }); 20 | 21 | describe('Pass a valid date as ng-model', () => { 22 | beforeEach(() => { 23 | // 2015-10-1 24 | scope.date = new Date(2015, 9, 1); 25 | // special method to test jquery plugin function 26 | spyOn($.fn, 'pickadate').and.callThrough(); 27 | element = $compile('')(scope); 28 | scope.$digest(); 29 | }); 30 | 31 | it('should call pickadate twice when initializing with valid date', () => { 32 | expect($.fn.pickadate).toHaveBeenCalledWith({format: 'yyyy-m-d'}); 33 | // this is in watch function 34 | expect($.fn.pickadate).toHaveBeenCalledWith('picker'); 35 | expect($.fn.pickadate.calls.count()).toEqual(2); 36 | }); 37 | 38 | it('should not call pickadate any more even if model changes', () => { 39 | // change model but watch function will not be called again 40 | scope.date = new Date(2016, 10, 1); 41 | scope.$digest(); 42 | expect($.fn.pickadate.calls.count()).toEqual(2); 43 | }); 44 | 45 | it('should display the model in correct format', () => { 46 | expect(element.val()).toEqual('2015-10-1'); 47 | }); 48 | }); 49 | 50 | describe('Pass a null/undefined value as ng-model', () => { 51 | beforeEach(() => { 52 | scope.date = null; 53 | // special method to test jquery plugin function 54 | spyOn($.fn, 'pickadate').and.callThrough(); 55 | element = $compile('')(scope); 56 | scope.$digest(); 57 | }); 58 | 59 | it('should only call pickadate once when initializing with null', () => { 60 | expect($.fn.pickadate.calls.count()).toEqual(1); 61 | }); 62 | 63 | it('should display nothing', () => { 64 | expect(element.val()).toEqual(''); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/dropdown-init.directive.js: -------------------------------------------------------------------------------- 1 | function DropdownInitDirective () { 2 | return { 3 | restrict: 'A', 4 | link 5 | }; 6 | 7 | function link (scope, element) { 8 | element.dropdown(); 9 | } 10 | } 11 | 12 | DropdownInitDirective.$inject = []; 13 | 14 | export default DropdownInitDirective; 15 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/dropdown-init.directive.spec.js: -------------------------------------------------------------------------------- 1 | import DropdownInitDirective from './dropdown-init.directive'; 2 | 3 | describe('DropdownInit Directive', () => { 4 | let scope; 5 | 6 | beforeEach(() => { 7 | angular.module('test', []) 8 | .directive('aioDropdownInit', DropdownInitDirective); 9 | angular.mock.module('test'); 10 | }); 11 | 12 | beforeEach(() => { 13 | angular.mock.inject(($rootScope, $compile) => { 14 | scope = $rootScope.$new(); 15 | spyOn($.fn, 'dropdown').and.callThrough(); 16 | $compile('')(scope); 17 | scope.$digest(); 18 | }); 19 | }); 20 | 21 | it('should call dropdown function when initialization', () => { 22 | expect($.fn.dropdown).toHaveBeenCalled(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/focus-me.directive.js: -------------------------------------------------------------------------------- 1 | function FocusMeDirective () { 2 | return { 3 | restrict: 'A', 4 | link 5 | }; 6 | 7 | function link (scope, element, attrs) { 8 | scope.$watch(attrs.aioFocusMe, (val) => { 9 | if (val) { 10 | scope.$evalAsync(() => { 11 | element[0].focus(); 12 | }); 13 | } 14 | }); 15 | } 16 | } 17 | 18 | FocusMeDirective.$inject = []; 19 | 20 | export default FocusMeDirective; 21 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/focus-me.directive.spec.js: -------------------------------------------------------------------------------- 1 | import FocusMeDirective from './focus-me.directive'; 2 | 3 | describe('FocusMe Directive', () => { 4 | let scope; 5 | let element; 6 | 7 | beforeEach(() => { 8 | angular.module('test', []) 9 | .directive('aioFocusMe', FocusMeDirective); 10 | angular.mock.module('test'); 11 | }); 12 | 13 | beforeEach(() => { 14 | angular.mock.inject(($rootScope, $compile) => { 15 | scope = $rootScope.$new(); 16 | scope.isFocus = true; 17 | element = $compile('')(scope); 18 | // spy needs to be put before $digest 19 | spyOn(element[0], 'focus'); 20 | }); 21 | }); 22 | 23 | it('should make element get focus when attribute is true', () => { 24 | scope.$digest(); 25 | expect(element[0].focus).toHaveBeenCalled(); 26 | }); 27 | 28 | it('should make element lose focus when attribute is false', () => { 29 | scope.isFocus = false; 30 | scope.$digest(); 31 | expect(element[0].focus).not.toHaveBeenCalled(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/loading-button.directive.js: -------------------------------------------------------------------------------- 1 | function LoadingButtonDirective () { 2 | return { 3 | restrict: 'A', 4 | link 5 | }; 6 | 7 | function link (scope, element, attrs) { 8 | const spinner = ''; 9 | scope.$watch(attrs.aioLoadingButton, (val) => { 10 | if (val) { 11 | element.prepend(spinner); 12 | } else { 13 | // jqLite only support find by tag name 14 | element.find('i').remove(); 15 | } 16 | }); 17 | } 18 | } 19 | 20 | LoadingButtonDirective.$inject = []; 21 | 22 | export default LoadingButtonDirective; 23 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/loading-button.directive.spec.js: -------------------------------------------------------------------------------- 1 | import LoadingButtonDirective from './loading-button.directive'; 2 | 3 | describe('LoadingButton Directive', () => { 4 | let scope; 5 | let element; 6 | 7 | beforeEach(() => { 8 | angular.module('test', []) 9 | .directive('aioLoadingButton', LoadingButtonDirective); 10 | angular.mock.module('test'); 11 | }); 12 | 13 | beforeEach(() => { 14 | angular.mock.inject(($rootScope, $compile) => { 15 | scope = $rootScope.$new(); 16 | scope.isLoading = false; 17 | element = $compile('')(scope); 18 | }); 19 | }); 20 | 21 | it('should not have loading spinner with false attribute', () => { 22 | scope.$digest(); 23 | expect(element.find('i').length).toEqual(0); 24 | }); 25 | 26 | it('should have loading spinner when attribute changes to true', () => { 27 | scope.isLoading = true; 28 | scope.$digest(); 29 | expect(element.find('i').length).toEqual(1); 30 | // change back to false and spinner will be removed 31 | scope.isLoading = false; 32 | scope.$digest(); 33 | expect(element.find('i').length).toEqual(0); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/select-init.directive.js: -------------------------------------------------------------------------------- 1 | function SelectInitDirective () { 2 | return { 3 | restrict: 'A', 4 | link 5 | }; 6 | 7 | function link (scope, element, attrs) { 8 | scope.$watch(attrs.ngModel, initSelect); 9 | function initSelect () { 10 | element.siblings('.caret').remove(); 11 | element.material_select(); 12 | } 13 | } 14 | } 15 | 16 | SelectInitDirective.$inject = []; 17 | 18 | export default SelectInitDirective; 19 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/select-init.directive.spec.js: -------------------------------------------------------------------------------- 1 | import SelectInitDirective from './select-init.directive'; 2 | 3 | describe('SelectInit Directive', () => { 4 | let scope; 5 | 6 | beforeEach(() => { 7 | angular.module('test', []) 8 | .directive('aioSelectInit', SelectInitDirective); 9 | angular.mock.module('test'); 10 | }); 11 | 12 | beforeEach(() => { 13 | angular.mock.inject(($rootScope, $compile) => { 14 | scope = $rootScope.$new(); 15 | spyOn($.fn, 'material_select').and.callThrough(); 16 | scope.fakeArr = ['a', 'b', 'c']; 17 | $compile('')(scope); 18 | scope.$digest(); 19 | }); 20 | }); 21 | 22 | it('should call select function when initialization', () => { 23 | expect($.fn.material_select).toHaveBeenCalled(); 24 | }); 25 | 26 | it('should call select when model changes', () => { 27 | scope.fakeArr = ['a']; 28 | scope.$digest(); 29 | expect($.fn.material_select.calls.count()).toEqual(2); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/tooltip-init.directive.js: -------------------------------------------------------------------------------- 1 | function TooltipInitDirective () { 2 | return { 3 | restrict: 'A', 4 | link 5 | }; 6 | 7 | function link (scope, element) { 8 | element.tooltip(); 9 | // destroy tooltip, otherwise it will still display 10 | scope.$on('$destroy', () => { 11 | element.tooltip('remove'); 12 | }); 13 | } 14 | } 15 | 16 | TooltipInitDirective.$inject = []; 17 | 18 | export default TooltipInitDirective; 19 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/tooltip-init.directive.spec.js: -------------------------------------------------------------------------------- 1 | import TooltipInitDirective from './tooltip-init.directive'; 2 | 3 | describe('TooltipInit Directive', () => { 4 | let scope; 5 | 6 | beforeEach(() => { 7 | angular.module('test', []) 8 | .directive('aioTooltipInit', TooltipInitDirective); 9 | angular.mock.module('test'); 10 | }); 11 | 12 | beforeEach(() => { 13 | angular.mock.inject(($rootScope, $compile) => { 14 | scope = $rootScope.$new(); 15 | spyOn($.fn, 'tooltip').and.callThrough(); 16 | $compile('')(scope); 17 | scope.$digest(); 18 | }); 19 | }); 20 | 21 | it('should call tooltip function when initialization', () => { 22 | expect($.fn.tooltip).toHaveBeenCalled(); 23 | }); 24 | 25 | it('should remove tooltip when destory', () => { 26 | expect($.fn.tooltip.calls.count()).toEqual(1); 27 | scope.$destroy(); 28 | expect($.fn.tooltip).toHaveBeenCalledWith('remove'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/validate-number.directive.js: -------------------------------------------------------------------------------- 1 | function ValidateNumberDirective () { 2 | return { 3 | require: 'ngModel', 4 | restrict: 'A', 5 | link 6 | }; 7 | 8 | function link (scope, element, attrs, ctrl) { 9 | const pattern = /^\d+(\.\d{1,2})?$/; 10 | ctrl.$validators.number = function numberValidator (modelValue, viewModel) { 11 | if (pattern.test(viewModel)) { 12 | return true; 13 | } 14 | return false; 15 | }; 16 | } 17 | } 18 | 19 | ValidateNumberDirective.$inject = []; 20 | 21 | export default ValidateNumberDirective; 22 | -------------------------------------------------------------------------------- /source/app/components/_common/directives/validate-number.directive.spec.js: -------------------------------------------------------------------------------- 1 | import ValidateNumberDirective from './validate-number.directive'; 2 | 3 | describe('ValidateNumber Directive', () => { 4 | let scope; 5 | let form; 6 | 7 | beforeEach(() => { 8 | angular.module('test', []) 9 | .directive('aioValidateNumber', ValidateNumberDirective); 10 | angular.mock.module('test'); 11 | }); 12 | 13 | beforeEach(() => { 14 | angular.mock.inject(($rootScope, $compile) => { 15 | scope = $rootScope.$new(); 16 | scope.model = null; 17 | $compile( 18 | `
19 | 20 |
` 21 | )(scope); 22 | scope.$digest(); 23 | form = scope.form; 24 | }); 25 | }); 26 | 27 | it('should make element valid with number input', () => { 28 | // input simulation 29 | form.numberInput.$setViewValue('123'); 30 | scope.$digest(); 31 | expect(scope.model).toEqual('123'); 32 | expect(form.numberInput.$valid).toBe(true); 33 | expect(form.numberInput.$error.number).not.toBeDefined(); 34 | }); 35 | 36 | it('should make element invalid with non number input', () => { 37 | form.numberInput.$setViewValue('abc'); 38 | scope.$digest(); 39 | expect(scope.model).not.toBeDefined(); 40 | expect(form.numberInput.$valid).toBe(false); 41 | expect(form.numberInput.$error.number).toBe(true); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /source/app/components/_common/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | // services 4 | import EventConstant from './services/event.constant'; 5 | import AjaxErrorHandlerService from './services/ajax-error-handler.service'; 6 | import ErrorService from './services/error.service'; 7 | import LoggerService from './services/logger.service'; 8 | import ResolveService from './services/resolve.service'; 9 | import RouterHelperProvider from './services/router-helper.provider'; 10 | import UserService from './services/user.service'; 11 | // directives 12 | import FocusMeDirective from './directives/focus-me.directive'; 13 | import LoadingButtonDirective from './directives/loading-button.directive'; 14 | import ValidateNumberDirective from './directives/validate-number.directive'; 15 | import DropdownInitDirective from './directives/dropdown-init.directive'; 16 | import TooltipInitDirective from './directives/tooltip-init.directive'; 17 | import SelectInitDirective from './directives/select-init.directive'; 18 | import DatepickerInitDirective from './directives/datepicker-init.directive'; 19 | // config 20 | import appConfig from './config'; 21 | // run 22 | import appRun from './run'; 23 | // production 24 | import {appProductionConfig, exceptionHandlerConfig} from './production/production.config'; 25 | 26 | const common = angular.module('app.common', []); 27 | 28 | common 29 | .constant('Event', EventConstant) 30 | .service('AjaxErrorHandler', AjaxErrorHandlerService) 31 | .service('Error', ErrorService) 32 | .service('Logger', LoggerService) 33 | .service('Resolve', ResolveService) 34 | .provider('RouterHelper', RouterHelperProvider) 35 | .service('UserAPI', UserService) 36 | .directive('aioFocusMe', FocusMeDirective) 37 | .directive('aioLoadingButton', LoadingButtonDirective) 38 | .directive('aioValidateNumber', ValidateNumberDirective) 39 | .directive('aioDropdownInit', DropdownInitDirective) 40 | .directive('aioTooltipInit', TooltipInitDirective) 41 | .directive('aioSelectInit', SelectInitDirective) 42 | .directive('aioDatepickerInit', DatepickerInitDirective) 43 | .config(appConfig); 44 | 45 | if (__PROD__) { // eslint-disable-line no-undef 46 | common 47 | .config(exceptionHandlerConfig) 48 | .config(appProductionConfig); 49 | } 50 | 51 | common.run(appRun); 52 | 53 | export default common; 54 | -------------------------------------------------------------------------------- /source/app/components/_common/production/exception-handler.decorator.js: -------------------------------------------------------------------------------- 1 | // Extend the original $exceptionHandler service. 2 | // * add error log prefix using exceptionPrefixProvider 3 | // * do other thing with error log 4 | exceptionHandlerDecorator.$inject = ['$delegate', 'Logger']; 5 | function exceptionHandlerDecorator ($delegate, logger) { 6 | return (exception, cause) => { // eslint-disable-line 7 | const appErrorPrefix = '[Aio Angular App Error] '; 8 | const errorData = {exception, cause}; 9 | exception.message = appErrorPrefix + exception.message; 10 | // $delegate(exception, cause); 11 | /** 12 | * Could add the error to a service's collection, 13 | * add errors to $rootScope, log errors to remote web server, 14 | * or log locally. Or throw hard. It is entirely up to you. 15 | * throw exception; 16 | * 17 | * @example 18 | * throw { message: 'error message we added' }; 19 | */ 20 | logger.error(exception.message, errorData); 21 | }; 22 | } 23 | 24 | export default exceptionHandlerDecorator; 25 | -------------------------------------------------------------------------------- /source/app/components/_common/production/exception-handler.decorator.spec.js: -------------------------------------------------------------------------------- 1 | import exceptionHandlerDecorator from './exception-handler.decorator'; 2 | 3 | describe('ExceptionHandler Decorator', () => { 4 | let Logger; 5 | let $timeout; 6 | 7 | // module defination, module load 8 | beforeEach(() => { 9 | angular.module('test', []) 10 | .config(($provide) => { 11 | $provide.decorator('$exceptionHandler', exceptionHandlerDecorator); 12 | }); 13 | angular.mock.module('test'); 14 | }); 15 | 16 | // mock dependences 17 | beforeEach(() => { 18 | angular.mock.module(($provide) => { 19 | $provide.value('Logger', jasmine.createSpyObj('Logger', ['error'])); 20 | }); 21 | }); 22 | 23 | // inject dependences 24 | beforeEach(() => { 25 | angular.mock.inject((_Logger_, _$timeout_) => { 26 | Logger = _Logger_; 27 | $timeout = _$timeout_; 28 | }); 29 | }); 30 | 31 | it('should call Logger.error when error happens', () => { 32 | const prefix = '[Aio Angular App Error]'; 33 | // try to throw a error 34 | const error = new Error('exception'); 35 | $timeout(() => { 36 | throw error; 37 | }); 38 | $timeout.flush(); 39 | expect(Logger.error).toHaveBeenCalledWith(`${prefix} exception`, { 40 | exception: error, 41 | cause: undefined // eslint-disable-line 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /source/app/components/_common/production/production.config.js: -------------------------------------------------------------------------------- 1 | import exceptionHandlerDecorator from './exception-handler.decorator'; 2 | 3 | appProductionConfig.$inject = ['$logProvider', '$compileProvider']; 4 | function appProductionConfig ($logProvider, $compileProvider) { 5 | $logProvider.debugEnabled(false); 6 | $compileProvider.debugInfoEnabled(false); 7 | } 8 | 9 | exceptionHandlerConfig.$inject = ['$provide']; 10 | function exceptionHandlerConfig ($provide) { 11 | // Use decorator to extend the original $exceptionHandler: 12 | $provide.decorator('$exceptionHandler', exceptionHandlerDecorator); 13 | } 14 | 15 | export {appProductionConfig, exceptionHandlerConfig}; 16 | -------------------------------------------------------------------------------- /source/app/components/_common/run.js: -------------------------------------------------------------------------------- 1 | appRun.$inject = ['$rootScope']; 2 | function appRun ($rootScope) { 3 | // TODO: need to figure out a btter way 4 | // IE hack 5 | $rootScope.ieClass = /MSIE|Trident/.test(window.navigator.userAgent) ? 'ie' : 'no-ie'; 6 | } 7 | 8 | export default appRun; 9 | -------------------------------------------------------------------------------- /source/app/components/_common/services/ajax-error-handler.service.js: -------------------------------------------------------------------------------- 1 | class AjaxErrorHandlerService { 2 | constructor (Error, $q) { 3 | Object.assign(this, {Error, $q}); 4 | } 5 | // directly reject with the human readable error message 6 | catcher (reason) { 7 | // reason is: 8 | // 1. either an error $http response 9 | // 2. or an error message returned by _success 10 | const type = typeof reason; 11 | let code = '$UNEXPECTED'; 12 | if (reason) { 13 | if (type === 'object') { 14 | code = reason.message; 15 | } else if (type === 'string') { 16 | code = reason; 17 | } 18 | } 19 | return this.$q.reject({ 20 | code, 21 | text: this.Error.getErrorMessage(code) 22 | }); 23 | } 24 | } 25 | 26 | AjaxErrorHandlerService.$inject = ['Error', '$q']; 27 | 28 | export default AjaxErrorHandlerService; 29 | -------------------------------------------------------------------------------- /source/app/components/_common/services/ajax-error-handler.service.spec.js: -------------------------------------------------------------------------------- 1 | import AjaxErrorHandlerService from './ajax-error-handler.service'; 2 | 3 | // for service, we can directly unit test the class without Angular 4 | describe('AjaxErrorHandler Service', () => { 5 | let ErrorService; 6 | let $q; 7 | let ajaxErrorHandler; 8 | 9 | beforeEach(() => { 10 | ErrorService = jasmine.createSpyObj('Error', ['getErrorMessage']); 11 | $q = jasmine.createSpyObj('$q', ['reject']); 12 | ajaxErrorHandler = new AjaxErrorHandlerService(ErrorService, $q); 13 | }); 14 | 15 | describe('constructor function', () => { 16 | it('should set passed parameters correctly by constructor', () => { 17 | expect(ajaxErrorHandler.Error).toBe(ErrorService); 18 | expect(ajaxErrorHandler.$q).toBe($q); 19 | }); 20 | }); 21 | 22 | describe('catcher function', () => { 23 | function expectCommon (input, errorCode) { 24 | ajaxErrorHandler.catcher(input); 25 | expect(ErrorService.getErrorMessage).toHaveBeenCalledWith(errorCode); 26 | expect($q.reject).toHaveBeenCalledWith({ 27 | code: errorCode, 28 | text: ErrorService.getErrorMessage(errorCode) 29 | }); 30 | } 31 | 32 | it('should get correct code/message with string input', () => { 33 | const input = 'test'; 34 | expectCommon(input, input); 35 | }); 36 | 37 | it('should get correct code/message with object input', () => { 38 | const input = { 39 | message: 'test' 40 | }; 41 | expectCommon(input, input.message); 42 | }); 43 | 44 | it('should get correct code/message with null input', () => { 45 | const defaultString = '$UNEXPECTED'; 46 | expectCommon(null, defaultString); 47 | }); 48 | 49 | it('should get correct code/message with number input', () => { 50 | const defaultString = '$UNEXPECTED'; 51 | expectCommon(123, defaultString); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /source/app/components/_common/services/error.service.js: -------------------------------------------------------------------------------- 1 | // all error messages 2 | const ERROR_MESSAGE = { 3 | // unexpected 4 | $UNEXPECTED: 'Server issue, please try later!', 5 | // login 6 | LOGIN_WRONG_EMAIL_PASSWORD_PAIR: 'Incorrect email or password, please try again!', 7 | LOGIN_USER_IN_LOCK: 'Your account is locked!', 8 | // phone 9 | PHONE_QUERY_NOT_FOUND: 'Sorry, the phone you queryed can not be found!', 10 | PHONE_UPDATE_NOT_FOUND: 'Sorry, the phone you updated can not be found!', 11 | PHONE_DELETE_NOT_FOUND: 'Sorry, the phone you deleted can not be found!' 12 | }; 13 | 14 | class ErrorService { 15 | getErrorMessage (errorCode) { 16 | return ERROR_MESSAGE[errorCode] || ERROR_MESSAGE.$UNEXPECTED; 17 | } 18 | } 19 | 20 | export default ErrorService; 21 | -------------------------------------------------------------------------------- /source/app/components/_common/services/error.service.spec.js: -------------------------------------------------------------------------------- 1 | import ErrorService from './error.service'; 2 | 3 | // for service, we can directly unit test the class without Angular 4 | describe('Error Service', () => { 5 | let error; 6 | 7 | beforeEach(() => { 8 | error = new ErrorService(); 9 | }); 10 | 11 | describe('getErrorMessage function', () => { 12 | it('should get correct error message with a known error', () => { 13 | const errorCode = 'LOGIN_USER_IN_LOCK'; 14 | const errorText = 'Your account is locked!'; 15 | expect(error.getErrorMessage(errorCode)).toEqual(errorText); 16 | }); 17 | 18 | it('should get correct error message with unknown error', () => { 19 | const errorCode = 'aaaa'; 20 | const errorText = 'Server issue, please try later!'; 21 | expect(error.getErrorMessage(errorCode)).toEqual(errorText); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /source/app/components/_common/services/event.constant.js: -------------------------------------------------------------------------------- 1 | // all events 2 | const EVENT = { 3 | AUTH_LOGIN: 'auth_login_event', 4 | AUTH_LOGOUT: 'auth_logout_event', 5 | AUTH_SESSION_VALID: 'auth_session_valid_event' 6 | }; 7 | 8 | export default EVENT; 9 | -------------------------------------------------------------------------------- /source/app/components/_common/services/event.constant.spec.js: -------------------------------------------------------------------------------- 1 | import EventConstant from './event.constant'; 2 | 3 | // for service, we can directly unit test the class without Angular 4 | describe('Event Constant', () => { 5 | it('should get correct event string with a known event', () => { 6 | expect(EventConstant.AUTH_LOGIN).toBe('auth_login_event'); 7 | }); 8 | 9 | it('should get undefined with unknown event', () => { 10 | expect(EventConstant.aaa).not.toBeDefined(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /source/app/components/_common/services/logger.service.js: -------------------------------------------------------------------------------- 1 | // A wrapper service for original $log 2 | class LoggerService { 3 | constructor ($log) { 4 | Object.assign(this, {$log}); 5 | } 6 | 7 | error (message, data) { 8 | this.$log.error(`Error: ${message}, ${data}`); 9 | } 10 | 11 | info (message, data) { 12 | this.$log.info(`Info: ${message}, ${data}`); 13 | } 14 | 15 | debug (message, data) { 16 | this.$log.debug(`Debug: ${message}, ${data}`); 17 | } 18 | 19 | warning (message, data) { 20 | this.$log.warn(`Warning: ${message}, ${data}`); 21 | } 22 | } 23 | 24 | LoggerService.$inject = ['$log']; 25 | 26 | export default LoggerService; 27 | -------------------------------------------------------------------------------- /source/app/components/_common/services/logger.service.spec.js: -------------------------------------------------------------------------------- 1 | import LoggerService from './logger.service'; 2 | 3 | // for service, we can directly unit test the class without Angular 4 | describe('Logger Service', () => { 5 | let $log; 6 | let logger; 7 | const message = 'message'; 8 | const data = 'data'; 9 | 10 | beforeEach(() => { 11 | $log = jasmine.createSpyObj('$log', ['error', 'info', 'debug', 'warn']); 12 | logger = new LoggerService($log); 13 | }); 14 | 15 | describe('constructor function', () => { 16 | it('should set passed parameters correctly by constructor', () => { 17 | expect(logger.$log).toBe($log); 18 | }); 19 | }); 20 | 21 | function expectCommon (func, prefix) { 22 | expect($log[func]).toHaveBeenCalledWith(`${prefix}: ${message}, ${data}`); 23 | } 24 | 25 | describe('error function', () => { 26 | it('should call $log.error with correct parameters', () => { 27 | logger.error(message, data); 28 | expectCommon('error', 'Error'); 29 | }); 30 | }); 31 | 32 | describe('info function', () => { 33 | it('should call $log.info with correct parameters', () => { 34 | logger.info(message, data); 35 | expectCommon('info', 'Info'); 36 | }); 37 | }); 38 | 39 | describe('debug function', () => { 40 | it('should call $log.debug with correct parameters', () => { 41 | logger.debug(message, data); 42 | expectCommon('debug', 'Debug'); 43 | }); 44 | }); 45 | 46 | describe('warning function', () => { 47 | it('should call $log.warning with correct parameters', () => { 48 | logger.warning(message, data); 49 | expectCommon('warn', 'Warning'); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /source/app/components/_common/services/resolve.service.js: -------------------------------------------------------------------------------- 1 | class ResolveService { 2 | login (UserAPI, $q) { 3 | if (UserAPI.isLoggedIn() !== true) { 4 | return UserAPI.checkLoggedInStatus() 5 | .catch(_error); 6 | } 7 | 8 | function _error () { 9 | return $q.reject('requireLogin'); 10 | } 11 | } 12 | } 13 | 14 | ResolveService.prototype.login.$inject = ['UserAPI', '$q']; 15 | 16 | export default ResolveService; 17 | -------------------------------------------------------------------------------- /source/app/components/_common/services/resolve.service.spec.js: -------------------------------------------------------------------------------- 1 | import ResolveService from './resolve.service'; 2 | 3 | // for service, we can directly unit test the class without Angular 4 | describe('Resolve Service', () => { 5 | let UserService; 6 | let $q; 7 | let Resolve; 8 | let $rootScope; 9 | 10 | beforeEach(() => { 11 | angular.module('test', []) 12 | .service('Resolve', ResolveService); 13 | angular.mock.module('test'); 14 | }); 15 | 16 | beforeEach(() => { 17 | angular.mock.inject((_$q_, _$rootScope_, _Resolve_) => { 18 | $q = _$q_; 19 | $rootScope = _$rootScope_; 20 | Resolve = _Resolve_; 21 | UserService = jasmine.createSpyObj('UserService', ['isLoggedIn', 'checkLoggedInStatus']); 22 | spyOn($q, 'reject').and.callThrough(); 23 | }); 24 | }); 25 | 26 | describe('login function', () => { 27 | describe('user has already logged in', () => { 28 | beforeEach(() => { 29 | UserService.isLoggedIn.and.returnValue(true); 30 | Resolve.login(UserService, $q); 31 | }); 32 | 33 | it('should not reject', () => { 34 | expect($q.reject).not.toHaveBeenCalled(); 35 | }); 36 | }); 37 | 38 | describe('user has not logged in', () => { 39 | let deferred; 40 | beforeEach(() => { 41 | deferred = $q.defer(); 42 | UserService.isLoggedIn.and.returnValue(false); 43 | UserService.checkLoggedInStatus.and.returnValue(deferred.promise); 44 | Resolve.login(UserService, $q); 45 | }); 46 | it('should not reject if UserService\'s checkLoggedInStatus resolves', () => { 47 | deferred.resolve(); 48 | $rootScope.$digest(); 49 | expect($q.reject).not.toHaveBeenCalled(); 50 | }); 51 | 52 | it('should reject if UserService\'s checkLoggedInStatus rejects', () => { 53 | deferred.reject(); 54 | $rootScope.$digest(); 55 | expect($q.reject).toHaveBeenCalledWith('requireLogin'); 56 | }); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /source/app/components/_common/services/router-helper.provider.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | const [handlingStateChangeError, hasOtherwise, stateCounts] = [ 4 | Symbol(), Symbol(), Symbol() 5 | ]; 6 | 7 | class RouterHelper { 8 | constructor (config, $stateProvider, $urlRouterProvider, 9 | $rootScope, $state, Logger, Resolve) { 10 | Object.assign(this, {config, $stateProvider, $urlRouterProvider, 11 | $rootScope, $state, Logger, Resolve}); 12 | // private variable 13 | this[handlingStateChangeError] = false; 14 | this[hasOtherwise] = false; 15 | this[stateCounts] = { 16 | errors: 0, 17 | changes: 0 18 | }; 19 | 20 | this.handleRoutingErrors(); 21 | this.updateDocTitle(); 22 | } 23 | 24 | configureStates (states, otherwisePath) { 25 | const self = this; 26 | states.forEach((state) => { 27 | // add login check if requireLogin is true 28 | const data = state.config.data; 29 | if (data && data.requireLogin === true) { 30 | state.config.resolve = angular.extend( 31 | state.config.resolve || {}, 32 | {loginResolve: self.Resolve.login} 33 | ); 34 | } 35 | state.config.resolve = 36 | angular.extend(state.config.resolve || {}, self.config.resolveAlways); 37 | this.$stateProvider.state(state.state, state.config); 38 | }); 39 | if (otherwisePath && !this[hasOtherwise]) { 40 | this[hasOtherwise] = true; 41 | this.$urlRouterProvider.otherwise(otherwisePath); 42 | } 43 | } 44 | 45 | handleRoutingErrors () { 46 | // Route cancellation: 47 | // On routing error, go to the dashboard. 48 | // Provide an exit clause if it tries to do it twice. 49 | this.$rootScope.$on('$stateChangeError', 50 | (event, toState, toParams, fromState, fromParams, error) => { 51 | if (this[handlingStateChangeError]) { 52 | return; 53 | } 54 | this[stateCounts].errors++; 55 | this[handlingStateChangeError] = true; 56 | const destination = (toState && 57 | (toState.title || toState.name || toState.loadedTemplateUrl)) || 58 | 'unknown target'; 59 | const errorMessage = (error && error.message) || error; 60 | const msg = `Error routing to ${destination}.\nReason: ${errorMessage}.`; 61 | this.Logger.warning(msg); 62 | // handle requireLogin issue 63 | if (error === 'requireLogin') { 64 | this.$state.prev = { 65 | state: toState.name, 66 | params: toParams 67 | }; 68 | this.$state.go('root.layout.login'); 69 | } else { 70 | this.$state.go('root.layout.home'); 71 | } 72 | } 73 | ); 74 | } 75 | 76 | getStates () { 77 | return this.$state.get(); 78 | } 79 | 80 | updateDocTitle () { 81 | this.$rootScope.$on('$stateChangeSuccess', 82 | (event, toState) => { 83 | this[stateCounts].changes++; 84 | this[handlingStateChangeError] = false; 85 | const title = `${toState.data.title} - ${this.config.mainTitle}`; 86 | this.$rootScope.title = title; // data bind to 87 | this.$rootScope._class = toState.data._class; // data bind to <body> 88 | } 89 | ); 90 | } 91 | } 92 | 93 | // Help configure the state-base ui.router 94 | class RouterHelperProvider { 95 | constructor ($locationProvider, $stateProvider, $urlRouterProvider) { 96 | Object.assign(this, {$locationProvider, $stateProvider, $urlRouterProvider}); 97 | 98 | this.config = { 99 | mainTitle: '', 100 | resolveAlways: {} 101 | }; 102 | this.$locationProvider.html5Mode(true); 103 | } 104 | 105 | configure (cfg) { 106 | angular.extend(this.config, cfg); 107 | } 108 | 109 | $get ($rootScope, $state, Logger, Resolve) { 110 | return new RouterHelper( 111 | this.config, this.$stateProvider, this.$urlRouterProvider, 112 | $rootScope, $state, Logger, Resolve); 113 | } 114 | } 115 | 116 | RouterHelperProvider.prototype.$get.$inject = [ 117 | '$rootScope', '$state', 'Logger', 'Resolve' 118 | ]; 119 | 120 | RouterHelperProvider.$inject = ['$locationProvider', '$stateProvider', '$urlRouterProvider']; 121 | 122 | export default RouterHelperProvider; 123 | -------------------------------------------------------------------------------- /source/app/components/_common/services/user.service.js: -------------------------------------------------------------------------------- 1 | const [isLoggedIn, userInfo] = [Symbol(), Symbol()]; 2 | class UserSerivce { 3 | constructor ($http, $q, $rootScope, Event, AjaxError) { 4 | Object.assign(this, {$http, $q, $rootScope, Event, AjaxError}); 5 | // private variable 6 | this[isLoggedIn] = false; 7 | this[userInfo] = null; 8 | } 9 | 10 | isLoggedIn () { 11 | return this[isLoggedIn]; 12 | } 13 | 14 | checkLoggedInStatus () { 15 | const self = this; 16 | return this.$http.get('api/user/loginstatus') 17 | .then(_success) 18 | .catch(_error); 19 | 20 | function _success (response) { 21 | const data = response.data; 22 | if (response.status === 200 && data.code === 0) { 23 | self._setUser(data.result.user); 24 | self.$rootScope.$broadcast(self.Event.AUTH_SESSION_VALID, data.result.user); 25 | return data.result.user; 26 | } 27 | return self.$q.reject(data.message); 28 | } 29 | 30 | function _error (reason) { 31 | self._clearUser(); 32 | return self.AjaxError.catcher(reason); 33 | } 34 | } 35 | 36 | login (email, password) { 37 | const self = this; 38 | const req = { 39 | email, 40 | password 41 | }; 42 | return this.$http.post('api/user/login', req) 43 | .then(_success) 44 | .catch(_error); 45 | 46 | function _success (response) { 47 | const data = response.data; 48 | if (response.status === 200 && data.code === 0) { 49 | self._setUser(data.result.user); 50 | self.$rootScope.$broadcast(self.Event.AUTH_LOGIN, data.result.user); 51 | return data.result.user; 52 | } 53 | return self.$q.reject(data.message); 54 | } 55 | 56 | function _error (reason) { 57 | self._clearUser(); 58 | return self.AjaxError.catcher(reason); 59 | } 60 | } 61 | 62 | logout () { 63 | const self = this; 64 | return this.$http.post('api/user/logout') 65 | .then(_success) 66 | .catch(_error); 67 | 68 | function _success (response) { 69 | const data = response.data; 70 | if (response.status === 200 && data.code === 0) { 71 | self._clearUser(); 72 | self.$rootScope.$broadcast(self.Event.AUTH_LOGOUT); 73 | } else { 74 | return self.$q.reject(data.message); 75 | } 76 | } 77 | 78 | function _error (reason) { 79 | // log out user even API is failed 80 | self._clearUser(); 81 | return self.AjaxError.catcher(reason); 82 | } 83 | } 84 | 85 | getUserInfo () { 86 | return this[userInfo]; 87 | } 88 | 89 | getProductSummary () { 90 | const self = this; 91 | return this.$http.get('api/user/products') 92 | .then(_success) 93 | .catch(this.AjaxError.catcher.bind(this.AjaxError)); 94 | 95 | function _success (response) { 96 | const data = response.data; 97 | if (response.status === 200 && data.code === 0) { 98 | return data.result.summary; 99 | } 100 | return self.$q.reject(data.message); 101 | } 102 | } 103 | 104 | _setUser (userData) { 105 | this[isLoggedIn] = true; 106 | this[userInfo] = userData; 107 | } 108 | 109 | _clearUser () { 110 | this[isLoggedIn] = false; 111 | this[userInfo] = null; 112 | } 113 | } 114 | 115 | UserSerivce.$inject = ['$http', '$q', '$rootScope', 'Event', 'AjaxErrorHandler']; 116 | 117 | export default UserSerivce; 118 | -------------------------------------------------------------------------------- /source/app/components/_common/styles/animation.styl: -------------------------------------------------------------------------------- 1 | .dissolve-animation 2 | &.ng-hide-remove, &.ng-hide-add 3 | transition 0.5s linear all 4 | &.ng-hide-remove.ng-hide-remove-active, &.ng-hide-add 5 | opacity 1 6 | &.ng-hide-add.ng-hide-add-active, &.ng-hide-remove 7 | opacity 0 8 | 9 | .icon-breath-animation 10 | animation icon-breath 1.5s ease-in-out infinite 11 | 12 | .icon-rotate-animation 13 | animation icon-rotate .8s linear infinite 14 | margin-right 5px 15 | 16 | @keyframes icon-breath 17 | from 18 | box-shadow 0 0 2px 5px rgba(255, 138, 138, 0.48) 19 | opacity 0.5 20 | to 21 | box-shadow none 22 | opacity 1 23 | 24 | @keyframes icon-rotate 25 | from 26 | transform rotate(0deg) 27 | to 28 | transform rotate(360deg) 29 | -------------------------------------------------------------------------------- /source/app/components/_common/styles/common.styl: -------------------------------------------------------------------------------- 1 | @import 'variable' 2 | @import 'mixin' 3 | @import 'responsive' 4 | -------------------------------------------------------------------------------- /source/app/components/_common/styles/fonts.styl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkyJie/angular1-webpack-starter/6e7b0f6fd04a94df996dfeecc447f38b167a5196/source/app/components/_common/styles/fonts.styl -------------------------------------------------------------------------------- /source/app/components/_common/styles/helper.styl: -------------------------------------------------------------------------------- 1 | @import './common' 2 | 3 | // handy class for show/hide in different screens 4 | .hide-sm 5 | +sm() 6 | display none 7 | 8 | .hide-gt-sm 9 | +generate-media($sizes[0]) 10 | display none 11 | 12 | .hide-md 13 | +md() 14 | display none 15 | 16 | .hide-gt-md 17 | +generate-media($sizes[1]) 18 | display none 19 | 20 | .hide-lg 21 | +lg() 22 | display none 23 | 24 | .hide-gt-lg 25 | +generate-media($sizes[2]) 26 | display none 27 | 28 | // handy class for margin/padding 29 | $marginPaddingStep = 8 30 | $marginPaddingMap = { 31 | 'm': 'margin', 32 | 'p': 'padding' 33 | } 34 | $directionMap = { 35 | '': '', 36 | 'l': 'left', 37 | 'r': 'right', 38 | 't': 'top', 39 | 'b': 'bottom' 40 | } 41 | // m1: margin 8px 42 | // ml2: margin-left 16px 43 | generate-margin-padding() 44 | for _prefix in $marginPaddingMap 45 | for _direction in $directionMap 46 | for _step in 1..5 47 | if _direction == '' 48 | _key = $marginPaddingMap[_prefix] 49 | else 50 | _key = $marginPaddingMap[_prefix] + '-' + $directionMap[_direction] 51 | _val = unit(_step * $marginPaddingStep, 'px') 52 | .{_prefix + _direction + _step} 53 | {_key}: _val 54 | 55 | generate-margin-padding() 56 | -------------------------------------------------------------------------------- /source/app/components/_common/styles/mixin.styl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkyJie/angular1-webpack-starter/6e7b0f6fd04a94df996dfeecc447f38b167a5196/source/app/components/_common/styles/mixin.styl -------------------------------------------------------------------------------- /source/app/components/_common/styles/responsive.styl: -------------------------------------------------------------------------------- 1 | // simple responsive utility 2 | 3 | // pass (min, max) will generate (min-width) and (max-width) 4 | // pass (min) will generate (min-width) 5 | generate-media(args...) 6 | _min_str = 'only screen and (min-width: %s)' 7 | _max_str = ' and (max-width: %s)' 8 | if length(args) == 1 9 | _condition = _min_str % unit(args[0], 'px') 10 | else 11 | _condition = (_min_str % unit(args[0], 'px')) + (_max_str % unit(args[1], 'px')) 12 | @media _condition 13 | {block} 14 | 15 | // small device: 0 ~ 600px 16 | sm() 17 | +generate-media(0, $sizes[0]) 18 | {block} 19 | 20 | gt-sm() 21 | +generate-media($sizes[0]) 22 | {block} 23 | 24 | // middle device: 600px ~ 960px 25 | md() 26 | +generate-media($sizes[0], $sizes[1]) 27 | {block} 28 | 29 | // large device: 960px ~ 1200px 30 | lg() 31 | +generate-media($sizes[1], $sizes[2]) 32 | {block} 33 | 34 | // extra large device: 1200px ~ 35 | xl() 36 | +generate-media($sizes[2]) 37 | {block} 38 | -------------------------------------------------------------------------------- /source/app/components/_common/styles/variable.styl: -------------------------------------------------------------------------------- 1 | // screen sizes from https://material.angularjs.org 2 | $sizes = 600 960 1200 3 | 4 | // width/height 5 | $headerHeight = 64px 6 | $headerHeightForMobile = 56px 7 | $sidebarWidth = 200px 8 | 9 | // color 10 | $successColor = #4CAF50 11 | $errorColor = #F44336 12 | $warningColor = #FF9800 13 | $infoColor = #2196F3 14 | 15 | $loadingBarColor = #FF9E80 16 | $loadingBgColor = #FBE9E7 17 | $loadingBarShodowColor = #F1D9A4 18 | -------------------------------------------------------------------------------- /source/app/components/_layout/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkyJie/angular1-webpack-starter/6e7b0f6fd04a94df996dfeecc447f38b167a5196/source/app/components/_layout/bg.png -------------------------------------------------------------------------------- /source/app/components/_layout/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import common from '../_common'; 4 | import appLayoutRun from './layout.route'; 5 | import {header} from '../header'; 6 | import {footer} from '../footer'; 7 | import {sidebar} from '../sidebar'; 8 | import {breadcrumb} from '../breadcrumb'; 9 | import {sidebarSm} from '../sidebar-sm'; 10 | 11 | export default angular.module('app.layout', [ 12 | common.name, 13 | header.name, 14 | footer.name, 15 | sidebar.name, 16 | breadcrumb.name, 17 | sidebarSm.name 18 | ]).run(appLayoutRun); 19 | -------------------------------------------------------------------------------- /source/app/components/_layout/layout.route.js: -------------------------------------------------------------------------------- 1 | import mainLayoutHtml from './main.layout.jade'; 2 | import {headerHtml, HeaderController} from '../header'; 3 | import {footerHtml, FooterController} from '../footer'; 4 | import {sidebarHtml, SidebarController} from '../sidebar'; 5 | import {breadcrumbHtml, BreadcrumbController} from '../breadcrumb'; 6 | import {sidebarSmHtml, SidebarSmController} from '../sidebar-sm'; 7 | 8 | appLayoutRun.$inject = ['RouterHelper']; 9 | function appLayoutRun (RouterHelper) { 10 | RouterHelper.configureStates(getStates()); 11 | } 12 | 13 | function getStates () { 14 | return [ 15 | { 16 | state: 'root', 17 | config: { 18 | abstract: true, 19 | url: '', 20 | template: mainLayoutHtml 21 | } 22 | }, 23 | { 24 | state: 'root.layout', 25 | config: { 26 | abstract: true, 27 | url: '', 28 | views: { 29 | header: { 30 | template: headerHtml, 31 | controller: `${HeaderController.name} as vm` 32 | }, 33 | sidebar: { 34 | template: sidebarHtml, 35 | controller: `${SidebarController.name} as vm` 36 | }, 37 | breadcrumb: { 38 | template: breadcrumbHtml, 39 | controller: `${BreadcrumbController.name} as vm` 40 | }, 41 | footer: { 42 | template: footerHtml, 43 | controller: `${FooterController.name} as vm` 44 | }, 45 | 'sidebar-sm': { 46 | template: sidebarSmHtml, 47 | controller: `${SidebarSmController.name} as vm` 48 | } 49 | } 50 | } 51 | } 52 | ]; 53 | } 54 | 55 | export default appLayoutRun; 56 | -------------------------------------------------------------------------------- /source/app/components/_layout/layout.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | body 4 | .wrapper 5 | display flex 6 | flex-direction column 7 | background-image url('./bg.png') 8 | background-size cover 9 | .ie & 10 | height 100vh 11 | .no-ie & 12 | min-height 100vh 13 | .content 14 | margin-top $headerHeight 15 | +sm() 16 | margin-top $headerHeightForMobile 17 | display flex 18 | flex 1 1 auto 19 | .main-content 20 | flex 1 1 auto 21 | display flex 22 | flex-direction column 23 | overflow hidden 24 | .main-view 25 | flex 1 1 auto 26 | display flex 27 | justify-content center 28 | align-items center 29 | padding 0.5rem 0 1rem 0 30 | +sm() 31 | margin-left 8px 32 | margin-right 8px 33 | &.ng-enter 34 | animation bounceIn .8s 35 | .full-width 36 | align-self flex-start 37 | padding 10px 30px 20px 30px 38 | width 100% 39 | 40 | .dropdown-content 41 | width auto !important 42 | 43 | .select-dropdown 44 | > li.disabled > span 45 | color rgba(0, 0, 0, .3) 46 | -------------------------------------------------------------------------------- /source/app/components/_layout/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkyJie/angular1-webpack-starter/6e7b0f6fd04a94df996dfeecc447f38b167a5196/source/app/components/_layout/logo.png -------------------------------------------------------------------------------- /source/app/components/_layout/main.layout.jade: -------------------------------------------------------------------------------- 1 | - require('../_common/styles/animation.styl') 2 | - require('../_common/styles/fonts.styl') 3 | - require('../_common/styles/helper.styl') 4 | - require('./layout.styl') 5 | //- header/content/footer view 6 | header.header-view(ui-view="header") 7 | .content 8 | .sidebar-view(ui-view="sidebar", ng-class="{'show': showSidebar}") 9 | .main-content 10 | .breadcrumb-view(ui-view="breadcrumb") 11 | .main-view(ui-view="main") 12 | footer.footer-view(ui-view="footer") 13 | .sidebar-sm-view(ui-view="sidebar-sm") 14 | -------------------------------------------------------------------------------- /source/app/components/banner/banner.directive.js: -------------------------------------------------------------------------------- 1 | import bannerHtml from './banner.jade'; 2 | 3 | function BannerDirective () { 4 | return { 5 | restrict: 'AE', 6 | scope: { 7 | text: '=' 8 | }, 9 | template: bannerHtml 10 | }; 11 | } 12 | 13 | BannerDirective.$inject = []; 14 | 15 | export default BannerDirective; 16 | -------------------------------------------------------------------------------- /source/app/components/banner/banner.directive.spec.js: -------------------------------------------------------------------------------- 1 | import BannerDirective from './banner.directive'; 2 | 3 | describe('Banner Directive', () => { 4 | let element; 5 | 6 | beforeEach(() => { 7 | angular.module('test', []) 8 | .directive('aioBanner', BannerDirective); 9 | angular.mock.module('test'); 10 | }); 11 | 12 | beforeEach(() => { 13 | angular.mock.inject(($compile, $rootScope) => { 14 | const scope = $rootScope.$new(); 15 | scope.text = 'test text'; 16 | element = $compile('<aio-banner text="text"></aio-banner>')(scope); 17 | scope.$digest(); 18 | }); 19 | }); 20 | 21 | it('should has correct content passed in', () => { 22 | expect(element.find('p').text()).toEqual('test text'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /source/app/components/banner/banner.jade: -------------------------------------------------------------------------------- 1 | .banner-view 2 | .card-panel.blue.lighten-4.p1 3 | p {{text}} 4 | -------------------------------------------------------------------------------- /source/app/components/banner/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import BannerDirective from './banner.directive'; 4 | 5 | const banner = angular.module('app.components.banner', []) 6 | .directive(`aioBanner`, BannerDirective); 7 | 8 | export default banner; 9 | -------------------------------------------------------------------------------- /source/app/components/breadcrumb/breadcrumb.controller.js: -------------------------------------------------------------------------------- 1 | class BreadcrumbController { 2 | constructor ($state, $rootScope) { 3 | Object.assign(this, {$state, $rootScope}); 4 | 5 | this._applyNewBreadcrumb(this.$state.current, this.$state.params); 6 | this.$rootScope.$on('$stateChangeSuccess', 7 | (event, toState, toParams) => { 8 | this._applyNewBreadcrumb(toState, toParams); 9 | }); 10 | } 11 | 12 | _applyNewBreadcrumb (state, params) { 13 | this.breadcrumbs = []; 14 | const curName = state.name; 15 | const parentStateNames = this._getAncestorStates(curName); 16 | parentStateNames.forEach((name) => { 17 | const stateConfig = this.$state.get(name); 18 | if (stateConfig.abstract) { 19 | return; 20 | } 21 | const breadcrumb = { 22 | link: name, 23 | text: stateConfig.breadcrumb 24 | }; 25 | this.breadcrumbs.push(breadcrumb); 26 | }); 27 | const length = this.breadcrumbs.length; 28 | if (params && length > 0) { 29 | const lastBreadcrumb = this.breadcrumbs[length - 1]; 30 | lastBreadcrumb.link = `${lastBreadcrumb.link}(${JSON.stringify(params)})`; 31 | } 32 | } 33 | 34 | _getAncestorStates (stateName) { 35 | const ancestors = []; 36 | const pieces = stateName.split('.'); 37 | if (pieces.length > 1) { 38 | for (let i = 1; i < pieces.length; i++) { 39 | const name = pieces.slice(0, i + 1); 40 | ancestors.push(name.join('.')); 41 | } 42 | } 43 | return ancestors; 44 | } 45 | } 46 | 47 | BreadcrumbController.$inject = ['$state', '$rootScope']; 48 | 49 | export default BreadcrumbController; 50 | -------------------------------------------------------------------------------- /source/app/components/breadcrumb/breadcrumb.controller.spec.js: -------------------------------------------------------------------------------- 1 | import BreadcrumbController from './breadcrumb.controller'; 2 | 3 | describe('Breadcrumb Controller', () => { 4 | let controller; 5 | let $rootScope; 6 | let $state; 7 | 8 | beforeEach(() => { 9 | $rootScope = jasmine.createSpyObj('$rootScope', ['$on']); 10 | $state = jasmine.createSpyObj('$state', ['current', 'params', 'get']); 11 | controller = new BreadcrumbController($state, $rootScope); 12 | }); 13 | 14 | describe('constructor function', () => { 15 | it('should init successfully', () => { 16 | expect(controller.$state).toBe($state); 17 | expect(controller.$rootScope).toBe($rootScope); 18 | expect($rootScope.$on).toHaveBeenCalled(); 19 | expect($rootScope.$on.calls.argsFor(0)[0]).toEqual('$stateChangeSuccess'); 20 | const callback = $rootScope.$on.calls.argsFor(0)[1]; 21 | spyOn(controller, '_applyNewBreadcrumb'); 22 | callback({}, 'a', 'b'); 23 | expect(controller._applyNewBreadcrumb).toHaveBeenCalledWith('a', 'b'); 24 | }); 25 | }); 26 | 27 | describe('_applyNewBreadcrumb function', () => { 28 | let currentState; 29 | beforeEach(() => { 30 | currentState = { 31 | name: 'a.b.c.d' 32 | }; 33 | $state.get.and.callFake((name) => { 34 | let config; 35 | switch (name) { 36 | case 'a.b': 37 | config = { 38 | abstract: true 39 | }; 40 | break; 41 | case 'a.b.c': 42 | config = { 43 | breadcrumb: 'ccc' 44 | }; 45 | break; 46 | case 'a.b.c.d': 47 | config = { 48 | breadcrumb: 'ddd' 49 | }; 50 | break; 51 | default: 52 | config = {}; 53 | } 54 | return config; 55 | }); 56 | }); 57 | 58 | it('should generate correct breadcrumb list without params', () => { 59 | controller._applyNewBreadcrumb(currentState); 60 | const breadcrumbList = controller.breadcrumbs; 61 | expect(breadcrumbList[0]).toEqual({ 62 | link: 'a.b.c', 63 | text: 'ccc' 64 | }); 65 | expect(breadcrumbList[1]).toEqual({ 66 | link: 'a.b.c.d', 67 | text: 'ddd' 68 | }); 69 | }); 70 | 71 | it('should generate correct breadcrumb list with params', () => { 72 | controller._applyNewBreadcrumb(currentState, {a: 1}); 73 | const breadcrumbList = controller.breadcrumbs; 74 | expect(breadcrumbList[0]).toEqual({ 75 | link: 'a.b.c', 76 | text: 'ccc' 77 | }); 78 | expect(breadcrumbList[1]).toEqual({ 79 | link: 'a.b.c.d({"a":1})', 80 | text: 'ddd' 81 | }); 82 | }); 83 | }); 84 | 85 | describe('_getAncestorStates function', () => { 86 | it('should get the correct ancestor states', () => { 87 | expect(controller._getAncestorStates('a.b.c.d')).toEqual(['a.b', 'a.b.c', 'a.b.c.d']); 88 | expect(controller._getAncestorStates('a.b.c')).toEqual(['a.b', 'a.b.c']); 89 | expect(controller._getAncestorStates('a.b')).toEqual(['a.b']); 90 | expect(controller._getAncestorStates('a')).toEqual([]); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /source/app/components/breadcrumb/breadcrumb.jade: -------------------------------------------------------------------------------- 1 | - require('./breadcrumb.styl') 2 | ol.breadcrumb.p1 3 | li.home-item 4 | a.btn-floating.white.waves-effect.waves-light(ui-sref="root.layout.home") 5 | i.mdi-action-home.black-text 6 | li.breadcrumb-item(ng-repeat="nav in vm.breadcrumbs") 7 | i.mdi-navigation-chevron-right.small.white-text 8 | a.btn.white.black-text.waves-effect.waves-light(ng-if="!$last", ui-sref="{{nav.link}}") 9 | | {{nav.text}} 10 | span.white-text.nav-text(ng-if="$last") {{nav.text}} 11 | -------------------------------------------------------------------------------- /source/app/components/breadcrumb/breadcrumb.styl: -------------------------------------------------------------------------------- 1 | .breadcrumb-view 2 | .breadcrumb 3 | margin 0 4 | list-style none 5 | li 6 | display inline-block 7 | .nav-text 8 | font-size 1.3rem 9 | display inline-block 10 | float right 11 | line-height 45px 12 | a 13 | margin-top -16px 14 | .breadcrumb-item 15 | margin-left -5px 16 | -------------------------------------------------------------------------------- /source/app/components/breadcrumb/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import BreadcrumbController from './breadcrumb.controller'; 4 | import breadcrumbHtml from './breadcrumb.jade'; 5 | import common from '../_common'; 6 | 7 | const breadcrumb = angular.module('app.components.breadcrumb', [common.name]) 8 | .controller(BreadcrumbController.name, BreadcrumbController); 9 | 10 | export {breadcrumb, breadcrumbHtml, BreadcrumbController}; 11 | -------------------------------------------------------------------------------- /source/app/components/footer/footer.controller.js: -------------------------------------------------------------------------------- 1 | class FooterController { 2 | constructor () { 3 | this.year = (new Date()).getFullYear(); 4 | } 5 | } 6 | 7 | FooterController.$inject = []; 8 | 9 | export default FooterController; 10 | -------------------------------------------------------------------------------- /source/app/components/footer/footer.controller.spec.js: -------------------------------------------------------------------------------- 1 | import FooterController from './footer.controller'; 2 | 3 | describe('Footer Controller', () => { 4 | let controller; 5 | beforeEach(() => { 6 | controller = new FooterController(); 7 | }); 8 | 9 | it('should have correct year', () => { 10 | expect(controller.year).toEqual((new Date()).getFullYear()); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /source/app/components/footer/footer.jade: -------------------------------------------------------------------------------- 1 | - require('./footer.styl') 2 | nav.blue-grey 3 | .nav-wrapper 4 | p.copyright.center-align 5 | a.white-text(href="https://github.com/PinkyJie/angular1-webpack-starter", target="_blank") Angular1 Webpack Starter 6 | | © {{::vm.year}} 7 | -------------------------------------------------------------------------------- /source/app/components/footer/footer.styl: -------------------------------------------------------------------------------- 1 | .footer-view 2 | flex none 3 | nav 4 | .copyright 5 | margin auto 6 | text-shadow 1px 1px rgba(0, 0, 0, .2) 7 | a 8 | text-decoration underline 9 | -------------------------------------------------------------------------------- /source/app/components/footer/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import FooterController from './footer.controller'; 4 | import footerHtml from './footer.jade'; 5 | import common from '../_common'; 6 | 7 | const footer = angular.module('app.components.footer', [common.name]) 8 | .controller(FooterController.name, FooterController); 9 | 10 | export {footer, footerHtml, FooterController}; 11 | -------------------------------------------------------------------------------- /source/app/components/header/header.controller.js: -------------------------------------------------------------------------------- 1 | class HeaderController { 2 | constructor ($rootScope, Event) { 3 | Object.assign(this, {$rootScope, Event}); 4 | 5 | // update header based on auth event 6 | this.$rootScope.$on(this.Event.AUTH_LOGIN, this.updateHeader.bind(this)); 7 | this.$rootScope.$on(this.Event.AUTH_LOGOUT, this.updateHeader.bind(this)); 8 | this.$rootScope.$on(this.Event.AUTH_SESSION_VALID, this.updateHeader.bind(this)); 9 | } 10 | 11 | updateHeader (e, userInfo) { 12 | if (userInfo) { 13 | this.isLoggedIn = true; 14 | this.userInfo = userInfo; 15 | } else { 16 | this.isLoggedIn = false; 17 | this.userInfo = null; 18 | } 19 | } 20 | 21 | switchSidebar () { 22 | this.$rootScope.showSidebar = !this.$rootScope.showSidebar; 23 | } 24 | } 25 | 26 | HeaderController.$inject = ['$rootScope', 'Event']; 27 | 28 | export default HeaderController; 29 | -------------------------------------------------------------------------------- /source/app/components/header/header.controller.spec.js: -------------------------------------------------------------------------------- 1 | import HeaderController from './header.controller'; 2 | 3 | describe('Header Controller', () => { 4 | let controller; 5 | let $rootScope; 6 | let EVENT; 7 | 8 | beforeEach(() => { 9 | $rootScope = jasmine.createSpyObj('$rootScope', ['$on']); 10 | EVENT = { 11 | AUTH_LOGIN: 1, 12 | AUTH_LOGOUT: 2, 13 | AUTH_SESSION_VALID: 3 14 | }; 15 | controller = new HeaderController($rootScope, EVENT); 16 | }); 17 | 18 | describe('constructor function', () => { 19 | it('should init successfully', () => { 20 | expect(controller.$rootScope).toBe($rootScope); 21 | expect(controller.Event).toBe(EVENT); 22 | expect($rootScope.$on.calls.count()).toEqual(3); 23 | expect($rootScope.$on.calls.argsFor(0)[0]).toEqual(1); 24 | expect($rootScope.$on.calls.argsFor(1)[0]).toEqual(2); 25 | expect($rootScope.$on.calls.argsFor(2)[0]).toEqual(3); 26 | }); 27 | }); 28 | 29 | describe('updateHeader function', () => { 30 | it('should set correct status with valid user info', () => { 31 | controller.updateHeader({}, 'user'); 32 | expect(controller.isLoggedIn).toBe(true); 33 | expect(controller.userInfo).toEqual('user'); 34 | }); 35 | 36 | it('should set correct status with invalid user info', () => { 37 | controller.updateHeader({}); 38 | expect(controller.isLoggedIn).toBe(false); 39 | expect(controller.userInfo).toEqual(null); 40 | }); 41 | }); 42 | 43 | describe('switchSidebar function', () => { 44 | it('should switch sidebar show/hide', () => { 45 | $rootScope.showSidebar = true; 46 | controller.switchSidebar(); 47 | expect($rootScope.showSidebar).toBe(false); 48 | controller.switchSidebar(); 49 | expect($rootScope.showSidebar).toBe(true); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /source/app/components/header/header.jade: -------------------------------------------------------------------------------- 1 | - require('./header.styl') 2 | - var logoPng = require('../_layout/logo.png') 3 | nav.blue 4 | .nav-wrapper 5 | img.logo(src= logoPng) 6 | a.brand-logo(ui-sref="root.layout.home") Aio Angular App 7 | ul.header-menus.right(ng-if="vm.isLoggedIn") 8 | li.header-user-name.hide-sm 9 | span {{vm.userInfo.name}} 10 | li.header-dropdown 11 | a.dropdown-button.waves-light.waves-effect(data-activates="header-dropdown-content", aio-dropdown-init) 12 | i.mdi-navigation-more-vert 13 | ul#header-dropdown-content.dropdown-content 14 | li.hide-gt-sm.center-align.dropdown-user-name 15 | span.grey-text {{vm.userInfo.name}} 16 | li.hide-gt-sm.divider 17 | li.logout-link 18 | a(ui-sref="root.layout.login({action: 'logout'})") 19 | i.mdi-action-exit-to-app.left 20 | | Logout 21 | -------------------------------------------------------------------------------- /source/app/components/header/header.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .header-view 4 | flex none 5 | position fixed 6 | width 100% 7 | z-index 999 8 | .nav-wrapper 9 | +sm() 10 | padding-left 0 11 | padding-right 0 12 | .logo 13 | width 44px 14 | height 44px 15 | margin ($headerHeight/2 - @height/2) 10px 16 | .brand-logo 17 | font-size 1.8rem 18 | font-weight 300 19 | .header-user-name 20 | &:hover 21 | background none 22 | .header-menus 23 | > li 24 | margin-left 8px 25 | -------------------------------------------------------------------------------- /source/app/components/header/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import HeaderController from './header.controller'; 4 | import headerHtml from './header.jade'; 5 | import common from '../_common'; 6 | 7 | const header = angular.module('app.components.header', [common.name]) 8 | .controller(HeaderController.name, HeaderController); 9 | 10 | export {header, headerHtml, HeaderController}; 11 | -------------------------------------------------------------------------------- /source/app/components/home-hero/home-hero.directive.js: -------------------------------------------------------------------------------- 1 | import homeHeroHtml from './home-hero.jade'; 2 | 3 | function HomeHeroDirective () { 4 | return { 5 | restrict: 'AE', 6 | scope: { 7 | getStartedLink: '@' 8 | }, 9 | template: homeHeroHtml 10 | }; 11 | } 12 | 13 | HomeHeroDirective.$inject = []; 14 | 15 | export default HomeHeroDirective; 16 | -------------------------------------------------------------------------------- /source/app/components/home-hero/home-hero.directive.spec.js: -------------------------------------------------------------------------------- 1 | import HomeHeroDirective from './home-hero.directive'; 2 | 3 | describe('HomeHero Directive', () => { 4 | let element; 5 | 6 | beforeEach(() => { 7 | angular.module('test', []) 8 | .directive('aioHomeHero', HomeHeroDirective); 9 | angular.mock.module('test'); 10 | }); 11 | 12 | beforeEach(() => { 13 | angular.mock.inject(($compile, $rootScope) => { 14 | const scope = $rootScope.$new(); 15 | const tempalte = '<aio-home-hero get-started-link="test.link"></aio-home-hero>'; 16 | element = $compile(tempalte)(scope); 17 | scope.$digest(); 18 | }); 19 | }); 20 | 21 | it('should populate the correct link in template', () => { 22 | expect(element.find('.btn-get-started').attr('ui-sref')).toEqual('test.link'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /source/app/components/home-hero/home-hero.jade: -------------------------------------------------------------------------------- 1 | - require('./home-hero.styl') 2 | .home-hero-view 3 | .card-panel.blue.darken-3.center-align.p3 4 | h4.title.white-text Aio Angular App 5 | h5.subtitle.grey-text.text-lighten-1 6 | | Awesome web app built on AngularJS & Material Design. 7 | a.btn-get-started.btn.btn-large.green.waves-effect.waves-light.mt2(ui-sref="{{getStartedLink}}") Get Started 8 | -------------------------------------------------------------------------------- /source/app/components/home-hero/home-hero.styl: -------------------------------------------------------------------------------- 1 | @import '../../components/_common/styles/common' 2 | 3 | .home-hero-view 4 | .card-panel 5 | .title 6 | font-weight 300 7 | padding-top 150px 8 | background url("../../components/_layout/logo.png") top center no-repeat 9 | .subtitle 10 | font-weight 300 11 | -------------------------------------------------------------------------------- /source/app/components/home-hero/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import HomeHeroDirective from './home-hero.directive'; 4 | 5 | const homeHero = angular.module('app.components.homeHero', []) 6 | .directive('aioHomeHero', HomeHeroDirective); 7 | 8 | export default homeHero; 9 | -------------------------------------------------------------------------------- /source/app/components/loading/config.js: -------------------------------------------------------------------------------- 1 | function appLoadingConfig (cfpLoadingBarProvider) { 2 | cfpLoadingBarProvider.includeSpinner = false; 3 | cfpLoadingBarProvider.loadingBarTemplate = [ 4 | '<div class="loading-view">', 5 | '<div class="progress"><div class="indeterminate"></div></div>', 6 | '</div>' 7 | ].join(''); 8 | } 9 | 10 | appLoadingConfig.$inject = ['cfpLoadingBarProvider']; 11 | 12 | export default appLoadingConfig; 13 | -------------------------------------------------------------------------------- /source/app/components/loading/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import ngLoadingBar from 'angular-loading-bar'; 3 | 4 | import appLoadingConfig from './config'; 5 | import './loading.styl'; 6 | 7 | const loading = angular.module('app.components.loading', [ 8 | ngLoadingBar 9 | ]) 10 | .config(appLoadingConfig); 11 | 12 | export default loading; 13 | -------------------------------------------------------------------------------- /source/app/components/loading/loading.styl: -------------------------------------------------------------------------------- 1 | @import '../../components/_common/styles/common' 2 | 3 | .loading-view 4 | width 100% 5 | z-index 1000 6 | position fixed 7 | top 0 8 | left 0 9 | .progress 10 | box-shadow: 0 0 5px $loadingBarShodowColor 11 | margin 0 12 | background-color $loadingBarColor 13 | .indeterminate 14 | background-color $loadingBgColor 15 | -------------------------------------------------------------------------------- /source/app/components/login-form/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import LoginFormController from './login-form.controller'; 4 | import LoginFormDirective from './login-form.directive'; 5 | 6 | const loginForm = angular.module('app.components.loginForm', []) 7 | .controller(LoginFormController.name, LoginFormController) 8 | .directive(`aioLoginForm`, LoginFormDirective); 9 | 10 | export default loginForm; 11 | -------------------------------------------------------------------------------- /source/app/components/login-form/login-form.controller.js: -------------------------------------------------------------------------------- 1 | class LoginFormController { 2 | constructor (UserAPI, $state) { 3 | Object.assign(this, {UserAPI, $state}); 4 | } 5 | 6 | login (credential) { 7 | const self = this; 8 | if (this.loginForm.$invalid) { 9 | return; 10 | } 11 | this.loginError = null; 12 | this.isRequest = true; 13 | this.UserAPI.login(credential.email, credential.password) 14 | .then(_success) 15 | .catch(_error); 16 | 17 | function _success () { 18 | // user was redirect to login page from other page 19 | if (self.$state.prev) { 20 | self.$state.go(self.$state.prev.state, self.$state.prev.params); 21 | self.$state.prev = null; 22 | } else { 23 | self.$state.go(self.routeAfterLogin); 24 | } 25 | } 26 | 27 | function _error (message) { 28 | self._setError('error', message.text); 29 | self.isRequest = false; 30 | } 31 | } 32 | 33 | _setError (type, text) { 34 | this.loginError = { 35 | type, 36 | text 37 | }; 38 | } 39 | } 40 | 41 | LoginFormController.$inject = ['UserAPI', '$state']; 42 | 43 | export default LoginFormController; 44 | -------------------------------------------------------------------------------- /source/app/components/login-form/login-form.controller.spec.js: -------------------------------------------------------------------------------- 1 | import LoginFormController from './login-form.controller'; 2 | 3 | describe('LoginForm Controller', () => { 4 | let controller; 5 | let UserAPI; 6 | let $state; 7 | let $q; 8 | let $rootScope; 9 | 10 | beforeEach(() => { 11 | angular.mock.inject((_$q_, _$rootScope_) => { 12 | $q = _$q_; 13 | $rootScope = _$rootScope_; 14 | UserAPI = jasmine.createSpyObj('UserAPI', ['login']); 15 | $state = jasmine.createSpyObj('$state', ['go']); 16 | controller = new LoginFormController(UserAPI, $state); 17 | }); 18 | }); 19 | 20 | describe('constructor function', () => { 21 | it('should init successfully', () => { 22 | expect(controller.UserAPI).toBe(UserAPI); 23 | expect(controller.$state).toBe($state); 24 | }); 25 | }); 26 | 27 | describe('login function', () => { 28 | let deferred; 29 | let credential; 30 | 31 | beforeEach(() => { 32 | credential = { 33 | email: 'email', 34 | password: 'password' 35 | }; 36 | deferred = $q.defer(); 37 | UserAPI.login.and.returnValue(deferred.promise); 38 | controller.loginForm = {}; 39 | }); 40 | 41 | it('should directly return when form is invalid', () => { 42 | controller.loginForm.$invalid = true; 43 | controller.login(credential); 44 | expect(UserAPI.login).not.toHaveBeenCalled(); 45 | }); 46 | 47 | describe('success callback', () => { 48 | beforeEach(() => { 49 | deferred.resolve(); 50 | controller.login(credential); 51 | }); 52 | 53 | it('should go to previous page after login', () => { 54 | $state.prev = { 55 | state: 'state', 56 | params: 'params' 57 | }; 58 | $rootScope.$digest(); 59 | expect($state.go).toHaveBeenCalledWith('state', 'params'); 60 | expect($state.prev).toBe(null); 61 | }); 62 | 63 | it('should go to normal page after login', () => { 64 | $state.prev = null; 65 | controller.routeAfterLogin = 'routeAfterLogin'; 66 | $rootScope.$digest(); 67 | expect($state.go).toHaveBeenCalledWith('routeAfterLogin'); 68 | }); 69 | }); 70 | 71 | it('should call error function with invalid credential', () => { 72 | controller.loginForm.$invalid = false; 73 | spyOn(controller, '_setError'); 74 | deferred.reject({text: 'text'}); 75 | controller.login(credential); 76 | $rootScope.$digest(); 77 | expect(controller._setError).toHaveBeenCalledWith('error', 'text'); 78 | expect(controller.isRequest).toBe(false); 79 | }); 80 | }); 81 | 82 | describe('_setError function', () => { 83 | it('should set passed in parameters to controller', () => { 84 | controller._setError('type', 'text'); 85 | expect(controller.loginError.type).toEqual('type'); 86 | expect(controller.loginError.text).toEqual('text'); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /source/app/components/login-form/login-form.directive.js: -------------------------------------------------------------------------------- 1 | import loginFormHtml from './login-form.jade'; 2 | import LoginFormController from './login-form.controller'; 3 | 4 | function LoginFormDirective () { 5 | return { 6 | restrict: 'AE', 7 | scope: {}, 8 | controller: LoginFormController.name, 9 | controllerAs: 'form', 10 | bindToController: { 11 | needCheckLogin: '=', 12 | userInfo: '=', 13 | routeAfterLogin: '@', 14 | loginError: '=' 15 | }, 16 | template: loginFormHtml 17 | }; 18 | } 19 | 20 | export default LoginFormDirective; 21 | -------------------------------------------------------------------------------- /source/app/components/login-form/login-form.jade: -------------------------------------------------------------------------------- 1 | - require('./login-form.styl') 2 | .login-form-view 3 | .login-checking.white-text(ng-if="form.needCheckLogin") 4 | i.medium(ng-class="{'mdi-notification-sync icon-rotate-animation': !form.userInfo, 'mdi-action-account-box': form.userInfo}") 5 | p.loading-text {{form.userInfo ? form.userInfo.name : 'Checking Login User...'}} 6 | 7 | form.card(name="form.loginForm", ng-submit="form.login(form.credential)", novalidate, autocomplete="off") 8 | .card-title.black-text.p2 9 | | Please sign in with your Email and Password 10 | .login-message.p1(class="{{form.loginError.type}}", ng-if="form.loginError") 11 | p.white-text {{form.loginError.text}} 12 | .card-content.p3 13 | .input-field 14 | i.mdi-communication-email.prefix 15 | input(name="email", type="email", ng-model="form.credential.email", required, aio-focus-me="true") 16 | label(for="email") Email 17 | .input-field 18 | i.mdi-communication-vpn-key.prefix 19 | input(name="password" type="password", ng-model="form.credential.password", required) 20 | label(for="password") Password 21 | .card-actions.center-align.p2 22 | button.btn.btn-large.blue.waves-effect.waves-light.btn-login(type="submit", 23 | ng-disabled="form.loginForm.$invalid || form.isRequest", 24 | ng-class="{'disabled': form.loginForm.$invalid || form.isRequest}", 25 | aio-loading-button="form.isRequest" 26 | ) Sign In 27 | -------------------------------------------------------------------------------- /source/app/components/login-form/login-form.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .login-form-view 4 | .login-checking 5 | display flex 6 | flex-direction column 7 | justify-content center 8 | align-items center 9 | z-index 10 10 | background rgba(0, 0, 0, .6) 11 | text-align center 12 | position absolute 13 | width 100% 14 | height 100% 15 | .loading-text 16 | animation pulse .8s linear infinite 17 | font-size 20px 18 | margin 0 19 | .card 20 | margin 0 21 | .card-title 22 | border-bottom 1px solid rgba(0, 0, 0, .2) 23 | .login-message 24 | &.error 25 | background-color $errorColor 26 | &.success 27 | background-color $successColor 28 | p 29 | font-size 18px 30 | margin-top 0 31 | -------------------------------------------------------------------------------- /source/app/components/modal/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import './modal.styl'; 4 | import ModalService from './modal.service'; 5 | 6 | const modal = angular.module('app.components.modal', []) 7 | .service('Modal', ModalService); 8 | 9 | export default modal; 10 | -------------------------------------------------------------------------------- /source/app/components/modal/modal.service.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | class ModalService { 4 | constructor ($timeout) { 5 | Object.assign(this, {$timeout}); 6 | } 7 | 8 | open (title, content, buttons, callback) { 9 | const modalView = angular.element('<div/>', { 10 | class: 'modal-view' 11 | }); 12 | const modalDiv = angular.element('<div/>', { 13 | id: 'modal', 14 | class: 'modal' 15 | }); 16 | modalDiv 17 | .append(this._buildHeaderAndContent(title, content)) 18 | .append(this._buildFooter(buttons, callback)); 19 | modalView 20 | .append(modalDiv) 21 | .appendTo('body'); 22 | this.$timeout(() => { 23 | $('#modal').openModal({ 24 | dismissible: false 25 | }); 26 | }, 100); 27 | } 28 | 29 | _buildHeaderAndContent (title, content) { 30 | const headerDiv = angular.element('<div/>', { 31 | class: 'modal-content' 32 | }); 33 | // title 34 | const titleDiv = angular.element('<h4/>', { 35 | text: title 36 | }); 37 | // content 38 | const contentDiv = angular.element('<p/>', { 39 | text: content 40 | }); 41 | headerDiv.append(titleDiv).append(contentDiv); 42 | return headerDiv; 43 | } 44 | 45 | _buildFooter (buttons, callback) { 46 | const footerDiv = angular.element('<div/>', { 47 | class: 'modal-footer' 48 | }); 49 | // ok button 50 | const okBtn = angular.element('<a/>', { 51 | class: 'btn btn-ok modal-action modal-close waves-effect waves-light mr3', 52 | text: buttons.ok 53 | }); 54 | okBtn.on('click', () => { 55 | if (angular.isDefined(callback)) { 56 | callback(true); 57 | } 58 | this._close(); 59 | }); 60 | footerDiv.append(okBtn); 61 | // cancel button 62 | if (angular.isDefined(buttons.cancel)) { 63 | const cancelBtn = angular.element('<a/>', { 64 | class: 'btn btn-cancel white black-text modal-action modal-close waves-effect waves-light mr2', 65 | text: buttons.cancel 66 | }); 67 | footerDiv.append(cancelBtn); 68 | cancelBtn.on('click', () => { 69 | if (angular.isDefined(callback)) { 70 | callback(false); 71 | } 72 | this._close(); 73 | }); 74 | } 75 | return footerDiv; 76 | } 77 | 78 | _close () { 79 | this.$timeout(() => { 80 | angular.element('.modal-view').remove(); 81 | }, 100); 82 | } 83 | } 84 | 85 | ModalService.$inject = ['$timeout']; 86 | 87 | export default ModalService; 88 | -------------------------------------------------------------------------------- /source/app/components/modal/modal.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .modal-view 4 | .modal 5 | .modal-footer 6 | a 7 | &:first-child 8 | margin-right 16px 9 | &:last-child 10 | margin-right 24px 11 | -------------------------------------------------------------------------------- /source/app/components/phone-form/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import PhoneFormController from './phone-form.controller'; 4 | import PhoneFromDirective from './phone-form.directive'; 5 | 6 | const phoneForm = angular.module('app.components.phoneForm', []) 7 | .controller(PhoneFormController.name, PhoneFormController) 8 | .directive(`aioPhoneForm`, PhoneFromDirective); 9 | 10 | export default phoneForm; 11 | -------------------------------------------------------------------------------- /source/app/components/phone-form/phone-form.controller.js: -------------------------------------------------------------------------------- 1 | class PhoneFormController { 2 | constructor () { 3 | this.allOS = ['Android', 'iOS', 'Windows Phone']; 4 | } 5 | submitForm (phone) { 6 | if (this.phoneForm.$invalid || !this.phone.releaseDate) { 7 | return; 8 | } 9 | this.isRequest = true; 10 | // call submit method passed in from outer scope 11 | this.submit({phone}) 12 | .then(() => { 13 | this._endRequest(); 14 | this.phoneForm.$setPristine(); 15 | }) 16 | .catch(this._endRequest.bind(this)); 17 | } 18 | 19 | _endRequest () { 20 | this.isRequest = false; 21 | } 22 | } 23 | 24 | PhoneFormController.$inject = []; 25 | 26 | export default PhoneFormController; 27 | -------------------------------------------------------------------------------- /source/app/components/phone-form/phone-form.controller.spec.js: -------------------------------------------------------------------------------- 1 | import PhoneFormController from './phone-form.controller'; 2 | 3 | describe('PhoneForm Controller', () => { 4 | let controller; 5 | let $q; 6 | let $rootScope; 7 | 8 | beforeEach(() => { 9 | angular.mock.inject((_$q_, _$rootScope_) => { 10 | $q = _$q_; 11 | $rootScope = _$rootScope_; 12 | controller = new PhoneFormController(); 13 | }); 14 | }); 15 | 16 | describe('constructor function', () => { 17 | it('should init successfully', () => { 18 | expect(controller.allOS).toEqual(['Android', 'iOS', 'Windows Phone']); 19 | }); 20 | }); 21 | 22 | describe('submitForm function', () => { 23 | let deferred; 24 | let phone; 25 | beforeEach(() => { 26 | phone = {phone: 'phone'}; 27 | deferred = $q.defer(); 28 | controller.phoneForm = {}; 29 | controller.phone = {}; 30 | controller.submit = jasmine.createSpy('submit'); 31 | controller.submit.and.returnValue(deferred.promise); 32 | }); 33 | 34 | describe('form is invalid', () => { 35 | it('should do nothing if form is invalid', () => { 36 | controller.phoneForm.$invalid = true; 37 | controller.submitForm(phone.phone); 38 | expect(controller.isRequest).not.toBeDefined(); 39 | expect(controller.submit).not.toHaveBeenCalled(); 40 | }); 41 | 42 | it('should do nothing if releaseDate is not set', () => { 43 | controller.phoneForm.$invalid = false; 44 | controller.phone.releaseDate = null; 45 | controller.submitForm(phone.phone); 46 | expect(controller.isRequest).not.toBeDefined(); 47 | expect(controller.submit).not.toHaveBeenCalled(); 48 | }); 49 | }); 50 | 51 | describe('form is valid', () => { 52 | beforeEach(() => { 53 | phone = {phone: 'phone'}; 54 | controller.phoneForm.$invalid = false; 55 | controller.phone.releaseDate = new Date(); 56 | spyOn(controller, '_endRequest'); 57 | controller.phoneForm.$setPristine = jasmine.createSpy('setPristine'); 58 | }); 59 | 60 | it('should set form pristine if submit is resolved', () => { 61 | deferred.resolve(); 62 | controller.submitForm(phone.phone); 63 | expect(controller.isRequest).toBe(true); 64 | expect(controller.submit).toHaveBeenCalledWith(phone); 65 | $rootScope.$digest(); 66 | expect(controller._endRequest).toHaveBeenCalled(); 67 | expect(controller.phoneForm.$setPristine).toHaveBeenCalled(); 68 | }); 69 | 70 | it('should not set form pristine if submit is rejected', () => { 71 | deferred.reject(); 72 | controller.submitForm(phone.phone); 73 | expect(controller.isRequest).toBe(true); 74 | expect(controller.submit).toHaveBeenCalledWith(phone); 75 | $rootScope.$digest(); 76 | expect(controller._endRequest).toHaveBeenCalled(); 77 | expect(controller.phoneForm.$setPristine).not.toHaveBeenCalled(); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('_endRequest function', () => { 83 | it('should end request as expected', () => { 84 | controller._endRequest(); 85 | expect(controller.isRequest).toBe(false); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /source/app/components/phone-form/phone-form.directive.js: -------------------------------------------------------------------------------- 1 | import PhoneFormController from './phone-form.controller'; 2 | import phoneFormHtml from './phone-form.jade'; 3 | 4 | function PhoneFormDirective () { 5 | return { 6 | restrict: 'AE', 7 | scope: {}, 8 | controller: PhoneFormController.name, 9 | controllerAs: 'form', 10 | bindToController: { 11 | phone: '=', 12 | state: '@', 13 | submit: '&', 14 | cancel: '&' 15 | }, 16 | template: phoneFormHtml 17 | }; 18 | } 19 | 20 | PhoneFormDirective.$inject = []; 21 | 22 | export default PhoneFormDirective; 23 | -------------------------------------------------------------------------------- /source/app/components/phone-form/phone-form.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .phone-form-view 4 | .form 5 | display flex 6 | flex-flow row wrap 7 | +sm() 8 | flex-direction column 9 | .form-group 10 | flex 1 1 auto 11 | margin 16px 12 | .form-item 13 | padding 8px 14 | border-bottom 1px dashed rgba(0, 0, 0, 0.12) 15 | .form-label 16 | display flex 17 | span 18 | flex 1 1 auto 19 | .form-value 20 | +sm() 21 | text-align right 22 | .form-field 23 | position relative 24 | display none 25 | .error-message 26 | &.ng-inactive 27 | display none 28 | &.ng-active 29 | display block 30 | border-radius 3px 31 | background-color rgba(0,0,0,.1) 32 | padding 5px 33 | position absolute 34 | right 0 35 | bottom 45px 36 | z-index 1 37 | &:after 38 | content '' 39 | border 7px solid transparent 40 | border-top-color rgba(0,0,0,0.1) 41 | position absolute 42 | right 2px 43 | margin-top 5px 44 | .form-control 45 | flex 1 1 auto 46 | .datepicker 47 | cursor pointer 48 | .dropdown-content 49 | width 100% 50 | .error-icon 51 | position absolute 52 | bottom 15px 53 | right 0 54 | 55 | .actions 56 | display none 57 | border-top 1px solid rgba(0, 0, 0, 0.12) 58 | flex 1 1 100% 59 | text-align center 60 | +sm() 61 | flex 1 1 auto 62 | button 63 | &.btn-cancel 64 | margin-right 50px 65 | +sm() 66 | display block 67 | &.btn-cancel 68 | margin auto 69 | &.btn-save 70 | margin 10px auto 0 71 | 72 | .form-group + .form-group 73 | +sm() 74 | margin-top 0 75 | 76 | &.editing, &.adding 77 | .form-group > .form-item 78 | border-bottom none 79 | .form-label 80 | display none 81 | .form-field 82 | display flex 83 | .actions 84 | display block 85 | 86 | &.adding 87 | .actions 88 | button.btn-cancel 89 | display none 90 | -------------------------------------------------------------------------------- /source/app/components/phone-table/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import PhoneTableController from './phone-table.controller'; 4 | import PhoneTableDirective from './phone-table.directive'; 5 | 6 | const phoneTable = angular.module('app.components.phoneTable', []) 7 | .controller(PhoneTableController.name, PhoneTableController) 8 | .directive(`aioPhoneTable`, PhoneTableDirective); 9 | 10 | export default phoneTable; 11 | -------------------------------------------------------------------------------- /source/app/components/phone-table/phone-table.controller.js: -------------------------------------------------------------------------------- 1 | class PhoneTableController { 2 | constructor () {} 3 | } 4 | 5 | PhoneTableController.$inject = []; 6 | 7 | export default PhoneTableController; 8 | -------------------------------------------------------------------------------- /source/app/components/phone-table/phone-table.directive.js: -------------------------------------------------------------------------------- 1 | import PhoneTableController from './phone-table.controller'; 2 | import phoneTableHtml from './phone-table.jade'; 3 | 4 | function PhoneTableDirective () { 5 | return { 6 | restrict: 'AE', 7 | scope: {}, 8 | controller: PhoneTableController.name, 9 | controllerAs: 'table', 10 | bindToController: { 11 | phones: '=', 12 | firstButtonClick: '&', 13 | secondButtonClick: '&' 14 | }, 15 | template: phoneTableHtml 16 | }; 17 | } 18 | 19 | PhoneTableDirective.$inject = []; 20 | 21 | export default PhoneTableDirective; 22 | -------------------------------------------------------------------------------- /source/app/components/phone-table/phone-table.directive.spec.js: -------------------------------------------------------------------------------- 1 | import PhoneTableDirective from './phone-table.directive'; 2 | import PhoneTableController from './phone-table.controller'; 3 | 4 | describe('PhoneTable Directive', () => { 5 | let element; 6 | let scope; 7 | 8 | beforeEach(() => { 9 | angular.module('test', []) 10 | .controller('PhoneTableController', PhoneTableController) 11 | .directive('aioPhoneTable', PhoneTableDirective); 12 | angular.mock.module('test'); 13 | }); 14 | 15 | beforeEach(() => { 16 | angular.mock.inject(($compile, $rootScope) => { 17 | scope = $rootScope.$new(); 18 | scope.phones = [ 19 | { 20 | id: 1, 21 | model: 'model1', 22 | os: 'os1', 23 | price: 1111 24 | }, 25 | { 26 | id: 2, 27 | model: 'model2', 28 | os: 'os2', 29 | price: 2222 30 | } 31 | ]; 32 | scope.firstButtonClick = jasmine.createSpy('firstButtonClick'); 33 | scope.secondButtonClick = jasmine.createSpy('secondButtonClick'); 34 | const template = ` 35 | <aio-phone-table phones="phones" 36 | first-button-click="firstButtonClick(scope, phone)" 37 | second-button-click="secondButtonClick(scope, phone)"> 38 | </aio-phone-table>`; 39 | element = $compile(template)(scope); 40 | scope.$digest(); 41 | }); 42 | }); 43 | 44 | it('should populate tempalte correctly', () => { 45 | const phoneItems = element.find('.phone-item'); 46 | expect(phoneItems.length).toEqual(2); 47 | expect($(phoneItems[0]).find('td:eq(0)').text()).toEqual('model1'); 48 | expect($(phoneItems[0]).find('td:eq(1)').text()).toEqual('os1'); 49 | expect($(phoneItems[0]).find('td:eq(2)').text()).toEqual('1111'); 50 | expect($(phoneItems[1]).find('td:eq(0)').text()).toEqual('model2'); 51 | expect($(phoneItems[1]).find('td:eq(1)').text()).toEqual('os2'); 52 | expect($(phoneItems[1]).find('td:eq(2)').text()).toEqual('2222'); 53 | }); 54 | 55 | it('should call first callback when clicking first button', () => { 56 | const phoneItems = element.find('.phone-item'); 57 | $(phoneItems[0]).find('.btn-first').click(); 58 | expect(scope.firstButtonClick).toHaveBeenCalled(); 59 | expect(scope.firstButtonClick.calls.argsFor(0)[1]).toEqual(scope.phones[0]); 60 | }); 61 | 62 | it('should call second callback when clicking second button', () => { 63 | const phoneItems = element.find('.phone-item'); 64 | $(phoneItems[1]).find('.btn-second').click(); 65 | expect(scope.secondButtonClick).toHaveBeenCalled(); 66 | expect(scope.secondButtonClick.calls.argsFor(0)[1]).toEqual(scope.phones[1]); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /source/app/components/phone-table/phone-table.jade: -------------------------------------------------------------------------------- 1 | - require('./phone-table.styl') 2 | .phone-table-view 3 | .card-panel.mt2 4 | table.highlight 5 | thead.heading 6 | tr 7 | th Model 8 | th.hide-sm OS 9 | th.hide-sm Price 10 | th.center-align Action 11 | tbody 12 | tr.empty-row(ng-if="table.phones.length === 0") 13 | td(colspan="4") No Phones found! 14 | tr.phone-item(ng-repeat="phone in table.phones track by phone.id") 15 | td 16 | span {{::phone.model}} 17 | td.hide-sm 18 | span {{::phone.os}} 19 | td.hide-sm 20 | span {{::phone.price}} 21 | td.center-align 22 | button.btn-first.btn-floating.blue.waves-light.waves-effect.tooltipped.mr1( 23 | ng-click="table.firstButtonClick({phone: phone})", 24 | data-position="top", data-delay="50", 25 | data-tooltip="View", aio-tooltip-init 26 | ) 27 | i.mdi-action-info 28 | button.btn-second.btn-floating.red.waves-effect.waves-light.tooltipped( 29 | ng-click="table.secondButtonClick({phone: phone})", 30 | data-position="top", data-delay="50", 31 | data-tooltip="Delete", aio-tooltip-init 32 | ) 33 | i.mdi-action-delete 34 | -------------------------------------------------------------------------------- /source/app/components/phone-table/phone-table.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .phone-table-view 4 | table 5 | .heading 6 | th 7 | font-weight bold 8 | +sm() 9 | text-align center 10 | .phone-item 11 | &.ng-enter 12 | animation slideInRight .8s 13 | &.ng-leave 14 | animation slideOutRight .8s 15 | -------------------------------------------------------------------------------- /source/app/components/sidebar-sm/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import SidebarSmController from './sidebar-sm.controller'; 4 | import sidebarSmHtml from './sidebar-sm.jade'; 5 | import common from '../_common'; 6 | 7 | const sidebarSm = angular.module('app.components.sidebarSm', [ 8 | common.name 9 | ]) 10 | .controller(SidebarSmController.name, SidebarSmController); 11 | 12 | export default {sidebarSm, sidebarSmHtml, SidebarSmController}; 13 | -------------------------------------------------------------------------------- /source/app/components/sidebar-sm/sidebar-sm.controller.js: -------------------------------------------------------------------------------- 1 | class SidebarSmController { 2 | constructor ($rootScope) { 3 | Object.assign(this, {$rootScope}); 4 | } 5 | 6 | toggleSidebar (flag) { 7 | if (typeof flag === 'undefined') { 8 | this.$rootScope.showSidebar = !this.$rootScope.showSidebar; 9 | } else { 10 | this.$rootScope.showSidebar = flag; 11 | } 12 | } 13 | } 14 | 15 | SidebarSmController.$inject = ['$rootScope']; 16 | 17 | export default SidebarSmController; 18 | -------------------------------------------------------------------------------- /source/app/components/sidebar-sm/sidebar-sm.controller.spec.js: -------------------------------------------------------------------------------- 1 | import SidebarSmController from './sidebar-sm.controller'; 2 | 3 | describe('SidebarSm Controller', () => { 4 | let controller; 5 | let $rootScope; 6 | 7 | beforeEach(() => { 8 | $rootScope = {}; 9 | controller = new SidebarSmController($rootScope); 10 | }); 11 | 12 | describe('constructor function', () => { 13 | it('should init successfully', () => { 14 | expect(controller.$rootScope).toBe($rootScope); 15 | }); 16 | }); 17 | 18 | describe('toggleSidebar function', () => { 19 | it('should set showSidebar to $rootScope', () => { 20 | controller.toggleSidebar(); 21 | expect($rootScope.showSidebar).toBe(true); 22 | controller.toggleSidebar(true); 23 | expect($rootScope.showSidebar).toBe(true); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /source/app/components/sidebar-sm/sidebar-sm.jade: -------------------------------------------------------------------------------- 1 | - require('./sidebar-sm.styl') 2 | a.sidebar-menu-fab.btn-floating.waves-light.waves-effect.red.hide-gt-sm(ng-if="hasSidebar", ng-click="vm.toggleSidebar()") 3 | i.mdi-image-dehaze 4 | .sidebar-backdrop.hide-gt-sm(ng-show="showSidebar", ng-click="vm.toggleSidebar(false)") 5 | -------------------------------------------------------------------------------- /source/app/components/sidebar-sm/sidebar-sm.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .sidebar-menu-fab 4 | position fixed 5 | opactiy 0.7 6 | bottom 15px 7 | right 15px 8 | z-index 999 9 | 10 | .sidebar-backdrop 11 | +sm() 12 | position absolute 13 | top 0 14 | z-index 990 15 | background-color rgba(0, 0, 0, 0.5) 16 | height 100% 17 | width 100% 18 | -------------------------------------------------------------------------------- /source/app/components/sidebar/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import SidebarController from './sidebar.controller'; 4 | import sidebarHtml from './sidebar.jade'; 5 | import common from '../_common'; 6 | 7 | const sidebar = angular.module('app.components.sidebar', [ 8 | common.name 9 | ]) 10 | .controller(SidebarController.name, SidebarController); 11 | 12 | export default {sidebar, sidebarHtml, SidebarController}; 13 | -------------------------------------------------------------------------------- /source/app/components/sidebar/sidebar.controller.js: -------------------------------------------------------------------------------- 1 | class SidebarController { 2 | constructor (RouterHelper, $scope, $rootScope) { 3 | Object.assign(this, {RouterHelper, $scope, $rootScope}); 4 | 5 | // generate sidebar nav menus 6 | this.navs = this._getNavMenus(); 7 | // tell others we have sidebar 8 | this.$rootScope.hasSidebar = true; 9 | this.$scope.$on('$destroy', () => { 10 | this.$rootScope.hasSidebar = false; 11 | }); 12 | } 13 | 14 | hideSidebar () { 15 | this.$rootScope.showSidebar = false; 16 | } 17 | 18 | _getNavMenus () { 19 | const navs = []; 20 | const allStates = this.RouterHelper.getStates(); 21 | allStates.forEach((state) => { 22 | if (state.sidebar) { 23 | const nav = state.sidebar; 24 | nav.link = state.name; 25 | navs.push(nav); 26 | } 27 | }); 28 | return navs; 29 | } 30 | } 31 | 32 | SidebarController.$inject = ['RouterHelper', '$scope', '$rootScope']; 33 | 34 | export default SidebarController; 35 | -------------------------------------------------------------------------------- /source/app/components/sidebar/sidebar.controller.spec.js: -------------------------------------------------------------------------------- 1 | import SidebarController from './sidebar.controller'; 2 | 3 | describe('Sidebar Controller', () => { 4 | let controller; 5 | let $rootScope; 6 | let $scope; 7 | let RouterHelper; 8 | 9 | beforeEach(() => { 10 | $rootScope = {}; 11 | $scope = jasmine.createSpyObj('$scope', ['$on']); 12 | RouterHelper = jasmine.createSpyObj('RouterHelper', ['getStates']); 13 | RouterHelper.getStates.and.returnValue([ 14 | { 15 | sidebar: { 16 | text: 'sidebar1' 17 | }, 18 | name: 'name1' 19 | }, 20 | { 21 | name: 'name2' 22 | } 23 | ]); 24 | controller = new SidebarController(RouterHelper, $scope, $rootScope); 25 | }); 26 | 27 | describe('constructor function', () => { 28 | it('should init successfully', () => { 29 | expect(controller.RouterHelper).toBe(RouterHelper); 30 | expect(controller.$scope).toBe($scope); 31 | expect(controller.$rootScope).toBe($rootScope); 32 | expect(controller.navs.length).toEqual(1); 33 | expect(controller.navs[0].text).toEqual('sidebar1'); 34 | expect(controller.navs[0].link).toEqual('name1'); 35 | expect($rootScope.hasSidebar).toBe(true); 36 | expect($scope.$on).toHaveBeenCalled(); 37 | expect($scope.$on.calls.argsFor(0)[0]).toEqual('$destroy'); 38 | const callback = $scope.$on.calls.argsFor(0)[1]; 39 | callback(); 40 | expect($rootScope.hasSidebar).toBe(false); 41 | }); 42 | }); 43 | 44 | describe('hideSidebar function', () => { 45 | it('should set hideSidebar on $rootScope', () => { 46 | controller.hideSidebar(); 47 | expect($rootScope.showSidebar).toBe(false); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /source/app/components/sidebar/sidebar.jade: -------------------------------------------------------------------------------- 1 | - require('./sidebar.styl') 2 | .sidebar 3 | ul.sidebar-menu.p1 4 | li.menu-item.p1(ng-repeat="nav in ::vm.navs", ui-sref-active="active") 5 | a.btn-flat.waves-effect.waves-light.link.pl1(ui-sref="{{::nav.link}}", ng-click="vm.hideSidebar()") 6 | i.left(class="{{::nav.icon}}") 7 | | {{::nav.text}} 8 | -------------------------------------------------------------------------------- /source/app/components/sidebar/sidebar.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .sidebar-view 4 | background-color white 5 | border-right 1px solid rgba(0, 0, 0, 0.2) 6 | +gt-sm() 7 | &.ng-enter 8 | animation slideInLeft .8s 9 | +sm() 10 | position fixed 11 | height 100% 12 | width $sidebarWidth 13 | box-shadow 3px 0 6px rgba(0, 0, 0, 0.2) 14 | transform translateX(-1 * $sidebarWidth) 15 | transition box-shadow, transform .6s cubic-bezier(0.23, 1, 0.32, 1) 16 | z-index 999 17 | &.show 18 | transform none 19 | .sidebar 20 | width $sidebarWidth 21 | .sidebar-menu 22 | list-style none 23 | .menu-item 24 | border-bottom 1px solid rgba(0, 0, 0, 0.2) 25 | i 26 | color #9e9e9e 27 | &.active 28 | border-radius 5px 29 | background-color rgba(146, 205, 255, 0.42) 30 | i 31 | color #4CAF50 32 | &:hover 33 | background-color rgba(146, 205, 255, 0.42) 34 | &:hover 35 | text-shadow 2px 2px rgba(0, 0, 0, 0.2) 36 | background-color #eee 37 | .link 38 | width 100% 39 | -------------------------------------------------------------------------------- /source/app/components/square-menu/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import SquareMenuDirective from './square-menu.directive'; 4 | 5 | const squareMenu = angular.module('app.components.squareMenu', []) 6 | .directive('aioSquareMenu', SquareMenuDirective); 7 | 8 | export default squareMenu; 9 | -------------------------------------------------------------------------------- /source/app/components/square-menu/square-menu.directive.js: -------------------------------------------------------------------------------- 1 | import squareMenuHtml from './square-menu.jade'; 2 | 3 | function SquareMenuDirective () { 4 | return { 5 | restrict: 'AE', 6 | scope: { 7 | menus: '=', 8 | colors: '=' 9 | }, 10 | template: squareMenuHtml 11 | }; 12 | } 13 | 14 | SquareMenuDirective.$inject = []; 15 | 16 | export default SquareMenuDirective; 17 | -------------------------------------------------------------------------------- /source/app/components/square-menu/square-menu.directive.spec.js: -------------------------------------------------------------------------------- 1 | import SquareMenuDirective from './square-menu.directive'; 2 | 3 | describe('SquareMenu Directive', () => { 4 | let element; 5 | let scope; 6 | 7 | beforeEach(() => { 8 | angular.module('test', []) 9 | .directive('aioSquareMenu', SquareMenuDirective); 10 | angular.mock.module('test'); 11 | }); 12 | 13 | beforeEach(() => { 14 | angular.mock.inject(($rootScope, $compile) => { 15 | scope = $rootScope.$new(); 16 | scope.menuList = [ 17 | { 18 | link: 'link1', 19 | icon: 'icon1', 20 | count: 1, 21 | name: 'name1' 22 | }, 23 | { 24 | link: 'link2', 25 | icon: 'icon2', 26 | count: 2, 27 | name: 'name2' 28 | }, 29 | { 30 | link: 'link3', 31 | icon: 'icon3', 32 | count: 3, 33 | name: 'name3' 34 | }, 35 | { 36 | link: 'link4', 37 | icon: 'icon4', 38 | count: 4, 39 | name: 'name4' 40 | } 41 | ]; 42 | scope.colorList = ['color0', 'color1', 'color2']; 43 | const template = '<aio-square-menu menus="menuList", colors="colorList"></aio-square-menu>'; 44 | element = $compile(template)(scope); 45 | scope.$digest(); 46 | }); 47 | }); 48 | 49 | it('should populate template correctly', () => { 50 | const menus = element.find('.box'); 51 | for (let i = 0; i < menus.length; i++) { 52 | const menu = angular.element(menus[i]); 53 | expect(menu.attr('ui-sref')).toEqual(`link${i + 1}`); 54 | expect(menu.attr('class')).toMatch(new RegExp(`color${i % 3}`)); 55 | expect(menu.find('i').attr('class')).toMatch(new RegExp(`icon${i + 1}`)); 56 | expect(menu.find('.count').text()).toEqual(`${i + 1}`); 57 | expect(menu.find('.name').text()).toEqual(`name${i + 1}`); 58 | } 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /source/app/components/square-menu/square-menu.jade: -------------------------------------------------------------------------------- 1 | - require('./square-menu.styl') 2 | .square-menu-view.boxes 3 | a.box.white-text(ng-repeat="menu in ::menus", 4 | ui-sref="{{::menu.link}}", 5 | class="{{::colors[$index % colors.length]}}" 6 | ) 7 | i.medium(ng-class="::menu.icon") 8 | span.count {{::menu.count}} 9 | p.name {{::menu.name}} 10 | -------------------------------------------------------------------------------- /source/app/components/square-menu/square-menu.styl: -------------------------------------------------------------------------------- 1 | @import '../_common/styles/common' 2 | 3 | .square-menu-view.boxes 4 | margin-top 20px 5 | display flex 6 | flex-wrap wrap 7 | justify-content center 8 | align-items center 9 | +sm() 10 | flex-direction column 11 | .box 12 | display block 13 | text-decoration none 14 | padding 10px 20px 15 | border-radius 2px 16 | text-shadow 0 0 2px #eee 17 | margin 20px 18 | width 200px 19 | height 150px 20 | &:hover 21 | box-shadow 2px 2px rgba(132, 143, 203, 0.4) 22 | .count 23 | display inline-block 24 | float right 25 | font-size 2rem 26 | margin-top 15px 27 | .name 28 | margin-top 10px 29 | font-weight bold 30 | text-transform uppercase 31 | margin-left 10px 32 | -------------------------------------------------------------------------------- /source/app/index.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | //- ng-app is determined by build script 3 | html(lang="en", ng-app= htmlWebpackPlugin.options.appName, ng-strict-di, ng-class="::ieClass") 4 | head 5 | //- This helps the ng-show/ng-hide animations start at the right place. 6 | //- Since Angular has this but needs to load, this gives us the class early. 7 | style 8 | .ng-hide { display: none!important; } 9 | title(ng-bind="title") 10 | meta(charset="utf-8") 11 | meta(http-equiv="X-UA-Compatible", content="IE=edge, chrome=1") 12 | meta(name="viewport", content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no") 13 | base(href="/") 14 | //- babel polyfill for IE 15 | //- conditional comment is not supported from IE 10 16 | //- TODO: how to include this only for IE 17 | script(src="polyfill.js") 18 | 19 | body(ng-class="_class + '-page'") 20 | .wrapper(ui-view) 21 | -------------------------------------------------------------------------------- /source/app/index.js: -------------------------------------------------------------------------------- 1 | // 3rd dependencies 2 | import 'materialize-css/bin/materialize.css'; 3 | import 'materialize-css/bin/materialize.js'; 4 | import 'animate.css/animate.css'; 5 | 6 | // angular 7 | import angular from 'angular'; 8 | import uiRouter from 'angular-ui-router'; 9 | import ngAnimate from 'angular-animate'; 10 | import ngMessage from 'angular-messages'; 11 | import 'oclazyload'; 12 | 13 | import layout from './components/_layout'; 14 | import loading from './components/loading'; 15 | import modal from './components/modal'; 16 | 17 | // routes 18 | import homeRoute from './pages/home/home.route'; 19 | import loginRoute from './pages/login/login.route'; 20 | import dashboardRoute from './pages/dashboard/dashboard.route'; 21 | import phoneRoute from './pages/phone/phone.route'; 22 | import notfoundRoute from './pages/404/404.route'; 23 | 24 | export default angular.module('app', [ 25 | 'oc.lazyLoad', 26 | uiRouter, 27 | ngAnimate, 28 | ngMessage, 29 | layout.name, 30 | loading.name, 31 | modal.name, 32 | homeRoute.name, 33 | loginRoute.name, 34 | dashboardRoute.name, 35 | phoneRoute.name, 36 | notfoundRoute.name 37 | ]); 38 | -------------------------------------------------------------------------------- /source/app/pages/404/404.jade: -------------------------------------------------------------------------------- 1 | .not-found-view 2 | h2.center-align 404 Not Found 3 | -------------------------------------------------------------------------------- /source/app/pages/404/404.route.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | appNotfoundRun.$inject = ['RouterHelper']; 4 | function appNotfoundRun (RouterHelper) { 5 | const otherwise = '/404'; 6 | RouterHelper.configureStates(getStates(), otherwise); 7 | } 8 | 9 | function getStates () { 10 | return [ 11 | { 12 | state: 'root.layout.notfound', 13 | config: { 14 | url: '/404', 15 | views: { 16 | 'main@root': { 17 | templateProvider: ['$q', ($q) => { 18 | return $q((resolve) => { 19 | require.ensure([], () => { 20 | resolve(require('./404.jade')); 21 | }, '404'); 22 | }); 23 | }] 24 | }, 25 | 'sidebar@root': {} 26 | }, 27 | data: { 28 | title: '404', 29 | _class: 'notfound' 30 | }, 31 | breadcrumb: '404', 32 | resolve: { 33 | loadModule: ['$q', '$ocLazyLoad', ($q, $ocLazyLoad) => { 34 | return $q((resolve) => { 35 | require.ensure([], () => { 36 | $ocLazyLoad.load({name: require('./index').name}); 37 | resolve(); 38 | }, '404'); 39 | }); 40 | }] 41 | } 42 | } 43 | } 44 | ]; 45 | } 46 | 47 | export default angular.module('app.routes.404', []) 48 | .run(appNotfoundRun); 49 | -------------------------------------------------------------------------------- /source/app/pages/404/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | export default angular.module('app.pages.404', []); 4 | -------------------------------------------------------------------------------- /source/app/pages/dashboard/dashboard.controller.js: -------------------------------------------------------------------------------- 1 | class DashboardController { 2 | constructor (UserAPI) { 3 | Object.assign(this, {UserAPI}); 4 | 5 | this.colors = ['indigo', 'red', 'pink']; 6 | 7 | const userInfo = this.UserAPI.getUserInfo(); 8 | this.welcomeMessage = `Welcome ${userInfo.name}!`; 9 | this._getProductsSummary(); 10 | } 11 | 12 | _getProductsSummary () { 13 | this.UserAPI.getProductSummary() 14 | .then((data) => { 15 | this.products = data; 16 | this.products.forEach((product) => { 17 | product.link = `root.layout.${product.name}`; 18 | }); 19 | }); 20 | } 21 | } 22 | 23 | DashboardController.$inject = ['UserAPI']; 24 | 25 | export default DashboardController; 26 | -------------------------------------------------------------------------------- /source/app/pages/dashboard/dashboard.controller.spec.js: -------------------------------------------------------------------------------- 1 | import DashboardController from './dashboard.controller'; 2 | 3 | describe('Dashboard Controller', () => { 4 | let controller; 5 | let UserAPI; 6 | let $q; 7 | let $rootScope; 8 | let deferred; 9 | 10 | beforeEach(() => { 11 | angular.mock.inject((_$q_, _$rootScope_) => { 12 | $q = _$q_; 13 | $rootScope = _$rootScope_; 14 | UserAPI = jasmine.createSpyObj('UserAPI', ['getUserInfo', 'getProductSummary']); 15 | UserAPI.getUserInfo.and.returnValue({ 16 | name: 'name' 17 | }); 18 | deferred = $q.defer(); 19 | UserAPI.getProductSummary.and.returnValue(deferred.promise); 20 | controller = new DashboardController(UserAPI); 21 | }); 22 | }); 23 | 24 | describe('constructor function', () => { 25 | it('should init successfully', () => { 26 | expect(controller.UserAPI).toBe(UserAPI); 27 | expect(controller.colors).toEqual(['indigo', 'red', 'pink']); 28 | expect(controller.welcomeMessage).toEqual('Welcome name!'); 29 | }); 30 | }); 31 | 32 | describe('_getProductsSummary function', () => { 33 | it('should get product data when API is resolved', () => { 34 | deferred.resolve([ 35 | { 36 | name: 'name1' 37 | }, 38 | { 39 | name: 'name2' 40 | }, 41 | { 42 | name: 'name3' 43 | } 44 | ]); 45 | controller._getProductsSummary(); 46 | $rootScope.$digest(); 47 | expect(controller.products.length).toEqual(3); 48 | expect(controller.products[0].name).toEqual('name1'); 49 | expect(controller.products[0].link).toEqual('root.layout.name1'); 50 | expect(controller.products[1].name).toEqual('name2'); 51 | expect(controller.products[1].link).toEqual('root.layout.name2'); 52 | expect(controller.products[2].name).toEqual('name3'); 53 | expect(controller.products[2].link).toEqual('root.layout.name3'); 54 | }); 55 | 56 | it('should not get product data when API is rejected', () => { 57 | deferred.reject(); 58 | controller._getProductsSummary(); 59 | $rootScope.$digest(); 60 | expect(controller.products).not.toBeDefined(); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /source/app/pages/dashboard/dashboard.jade: -------------------------------------------------------------------------------- 1 | - require('./dashboard.styl') 2 | .dashboard-view.full-width 3 | aio-banner(text="vm.welcomeMessage") 4 | aio-square-menu(menus="vm.products", colors="vm.colors") 5 | -------------------------------------------------------------------------------- /source/app/pages/dashboard/dashboard.route.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | appDashboardRun.$inject = ['RouterHelper']; 4 | function appDashboardRun (RouterHelper) { 5 | RouterHelper.configureStates(getStates()); 6 | } 7 | 8 | function getStates () { 9 | return [ 10 | { 11 | state: 'root.layout.dashboard', 12 | config: { 13 | url: '/dashboard', 14 | views: { 15 | 'main@root': { 16 | templateProvider: ['$q', ($q) => { 17 | return $q((resolve) => { 18 | require.ensure([], () => { 19 | resolve(require('./dashboard.jade')); 20 | }, 'dashboard'); 21 | }); 22 | }], 23 | controller: 'DashboardController as vm' 24 | } 25 | }, 26 | data: { 27 | title: 'Dashboard', 28 | _class: 'dashboard', 29 | requireLogin: true 30 | }, 31 | sidebar: { 32 | icon: 'mdi-action-dashboard', 33 | text: 'Dashboard' 34 | }, 35 | breadcrumb: 'Dashboard', 36 | resolve: { 37 | loadModule: ['$q', '$ocLazyLoad', ($q, $ocLazyLoad) => { 38 | return $q((resolve) => { 39 | require.ensure([], () => { 40 | $ocLazyLoad.load({name: require('./index').name}); 41 | resolve(); 42 | }, 'dashboard'); 43 | }); 44 | }] 45 | } 46 | } 47 | } 48 | ]; 49 | } 50 | 51 | export default angular.module('app.routes.dashboard', []) 52 | .run(appDashboardRun); 53 | -------------------------------------------------------------------------------- /source/app/pages/dashboard/dashboard.styl: -------------------------------------------------------------------------------- 1 | @import '../../components/_common/styles/common' 2 | -------------------------------------------------------------------------------- /source/app/pages/dashboard/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import DashboardController from './dashboard.controller'; 4 | 5 | import banner from '../../components/banner'; 6 | import squareMenu from '../../components/square-menu'; 7 | 8 | export default angular.module('app.pages.dashboard', [ 9 | banner.name, 10 | squareMenu.name 11 | ]) 12 | .controller(DashboardController.name, DashboardController); 13 | -------------------------------------------------------------------------------- /source/app/pages/home/home.jade: -------------------------------------------------------------------------------- 1 | - require('./home.styl') 2 | .home-view 3 | aio-home-hero(get-started-link="root.layout.login") 4 | -------------------------------------------------------------------------------- /source/app/pages/home/home.route.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | appHomeRun.$inject = ['RouterHelper']; 4 | function appHomeRun (RouterHelper) { 5 | RouterHelper.configureStates(getStates()); 6 | } 7 | 8 | function getStates () { 9 | return [ 10 | { 11 | state: 'root.layout.home', 12 | config: { 13 | url: '/', 14 | views: { 15 | 'main@root': { 16 | templateProvider: ['$q', ($q) => { 17 | return $q((resolve) => { 18 | require.ensure([], () => { 19 | resolve(require('./home.jade')); 20 | }, 'home'); 21 | }); 22 | }] 23 | }, 24 | 'sidebar@root': {}, 25 | 'breadcrumb@root': {} 26 | }, 27 | data: { 28 | title: 'Home', 29 | _class: 'home' 30 | }, 31 | resolve: { 32 | loadModule: ['$q', '$ocLazyLoad', ($q, $ocLazyLoad) => { 33 | return $q((resolve) => { 34 | require.ensure([], () => { 35 | $ocLazyLoad.load({name: require('./index').name}); 36 | resolve(); 37 | }, 'home'); 38 | }); 39 | }] 40 | } 41 | } 42 | } 43 | ]; 44 | } 45 | 46 | export default angular.module('app.routes.home', []) 47 | .run(appHomeRun); 48 | -------------------------------------------------------------------------------- /source/app/pages/home/home.styl: -------------------------------------------------------------------------------- 1 | @import '../../components/_common/styles/common' 2 | -------------------------------------------------------------------------------- /source/app/pages/home/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import homeHero from '../../components/home-hero'; 4 | 5 | export default angular.module('app.pages.home', [ 6 | homeHero.name 7 | ]); 8 | -------------------------------------------------------------------------------- /source/app/pages/login/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import loginForm from '../../components/login-form'; 4 | import LoginController from './login.controller'; 5 | 6 | export default angular.module('app.pages.login', [ 7 | loginForm.name 8 | ]) 9 | .controller(LoginController.name, LoginController); 10 | -------------------------------------------------------------------------------- /source/app/pages/login/login.controller.js: -------------------------------------------------------------------------------- 1 | class LoginController { 2 | constructor (UserAPI, $state, $timeout) { 3 | Object.assign(this, {UserAPI, $state, $timeout}); 4 | 5 | this.routeAfterLogin = 'root.layout.dashboard'; 6 | 7 | // handle logout 8 | const action = this.$state.params.action; 9 | if (action === 'logout') { 10 | this.needCheckLogin = false; 11 | this.UserAPI.logout() 12 | .then(() => { 13 | this.loginError = { 14 | type: 'success', 15 | text: 'You have been successfully logged out!' 16 | }; 17 | }); 18 | } else { 19 | this.userInfo = null; 20 | this.needCheckLogin = true; 21 | const self = this; 22 | // check login status firstly 23 | this.UserAPI.checkLoggedInStatus() 24 | .then((data) => { 25 | self.userInfo = data; 26 | self.$timeout(() => { 27 | self.$state.go(self.routeAfterLogin); 28 | }, 1000); 29 | }) 30 | .catch(() => { 31 | self.needCheckLogin = false; 32 | }); 33 | } 34 | } 35 | } 36 | 37 | LoginController.$inject = ['UserAPI', '$state', '$timeout']; 38 | 39 | export default LoginController; 40 | -------------------------------------------------------------------------------- /source/app/pages/login/login.controller.spec.js: -------------------------------------------------------------------------------- 1 | import LoginController from './login.controller'; 2 | 3 | describe('Login Controller', () => { 4 | let controller; 5 | let UserAPI; 6 | let $state; 7 | let $timeout; 8 | let $q; 9 | let $rootScope; 10 | let logoutDefer; 11 | let checkLoggedInStatusDefer; 12 | 13 | beforeEach(() => { 14 | angular.mock.inject((_$q_, _$rootScope_) => { 15 | $q = _$q_; 16 | $rootScope = _$rootScope_; 17 | UserAPI = jasmine.createSpyObj('UserAPI', ['logout', 'checkLoggedInStatus']); 18 | $state = jasmine.createSpyObj('$state', ['go']); 19 | $state.params = {}; 20 | $timeout = jasmine.createSpy('$timeout'); 21 | logoutDefer = $q.defer(); 22 | checkLoggedInStatusDefer = $q.defer(); 23 | UserAPI.logout.and.returnValue(logoutDefer.promise); 24 | UserAPI.checkLoggedInStatus.and.returnValue(checkLoggedInStatusDefer.promise); 25 | }); 26 | }); 27 | 28 | describe('constructor function', () => { 29 | it('should init successfully', () => { 30 | controller = new LoginController(UserAPI, $state, $timeout); 31 | expect(controller.UserAPI).toBe(UserAPI); 32 | expect(controller.$state).toBe($state); 33 | expect(controller.$timeout).toBe($timeout); 34 | expect(controller.routeAfterLogin).toEqual('root.layout.dashboard'); 35 | }); 36 | 37 | describe('handle logout', () => { 38 | beforeEach(() => { 39 | $state.params.action = 'logout'; 40 | controller = new LoginController(UserAPI, $state, $timeout); 41 | }); 42 | 43 | it('should handle logout with logout parameter', () => { 44 | expect(controller.needCheckLogin).toBe(false); 45 | logoutDefer.resolve(); 46 | $rootScope.$digest(); 47 | expect(controller.loginError.type).toEqual('success'); 48 | expect(controller.loginError.text).toEqual('You have been successfully logged out!'); 49 | }); 50 | 51 | it('should not show error if logout failed', () => { 52 | logoutDefer.reject(); 53 | $rootScope.$digest(); 54 | expect(controller.loginError).not.toBeDefined(); 55 | }); 56 | }); 57 | 58 | describe('handle automatic login', () => { 59 | beforeEach(() => { 60 | controller = new LoginController(UserAPI, $state, $timeout); 61 | }); 62 | 63 | it('should automatically log user in if user has already logged in', () => { 64 | expect(controller.userInfo).toBe(null); 65 | expect(controller.needCheckLogin).toBe(true); 66 | checkLoggedInStatusDefer.resolve('user info'); 67 | $rootScope.$digest(); 68 | expect(controller.userInfo).toEqual('user info'); 69 | expect($timeout).toHaveBeenCalled(); 70 | expect($timeout.calls.argsFor(0)[1]).toEqual(1000); 71 | const callback = $timeout.calls.argsFor(0)[0]; 72 | callback(); 73 | expect($state.go).toHaveBeenCalledWith('root.layout.dashboard'); 74 | }); 75 | 76 | it('should not log user in if checkLoggedInStatus rejects', () => { 77 | expect(controller.userInfo).toBe(null); 78 | expect(controller.needCheckLogin).toBe(true); 79 | checkLoggedInStatusDefer.reject(); 80 | $rootScope.$digest(); 81 | expect(controller.userInfo).toBe(null); 82 | expect(controller.needCheckLogin).toBe(false); 83 | }); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /source/app/pages/login/login.jade: -------------------------------------------------------------------------------- 1 | - require('./login.styl') 2 | .login-view 3 | aio-login-form( 4 | need-check-login="vm.needCheckLogin", 5 | user-info="vm.userInfo", 6 | route-after-login="{{vm.routeAfterLogin}}", 7 | login-error="vm.loginError" 8 | ) 9 | -------------------------------------------------------------------------------- /source/app/pages/login/login.route.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | appLoginRun.$inject = ['RouterHelper']; 4 | function appLoginRun (RouterHelper) { 5 | RouterHelper.configureStates(getStates()); 6 | } 7 | 8 | function getStates () { 9 | return [ 10 | { 11 | state: 'root.layout.login', 12 | config: { 13 | url: '/login?action', 14 | views: { 15 | 'main@root': { 16 | templateProvider: ['$q', ($q) => { 17 | return $q((resolve) => { 18 | require.ensure([], () => { 19 | resolve(require('./login.jade')); 20 | }, 'login'); 21 | }); 22 | }], 23 | controller: 'LoginController as vm' 24 | }, 25 | 'breadcrumb@root': {}, 26 | 'sidebar@root': {} 27 | }, 28 | data: { 29 | title: 'Login', 30 | _class: 'login' 31 | }, 32 | resolve: { 33 | loadModule: ['$q', '$ocLazyLoad', ($q, $ocLazyLoad) => { 34 | return $q((resolve) => { 35 | require.ensure([], () => { 36 | $ocLazyLoad.load({name: require('./index').name}); 37 | resolve(); 38 | }, 'login'); 39 | }); 40 | }] 41 | } 42 | } 43 | } 44 | ]; 45 | } 46 | 47 | export default angular.module('app.routes.login', []) 48 | .run(appLoginRun); 49 | -------------------------------------------------------------------------------- /source/app/pages/login/login.styl: -------------------------------------------------------------------------------- 1 | @import '../../components/_common/styles/common' 2 | 3 | .login-view 4 | position relative 5 | -------------------------------------------------------------------------------- /source/app/pages/phone/add/phone-add.controller.js: -------------------------------------------------------------------------------- 1 | class PhoneAddController { 2 | constructor (PhoneAPI, $state, $q, Modal) { 3 | Object.assign(this, {PhoneAPI, $state, $q, Modal}); 4 | 5 | this.phone = {}; 6 | this.state = 'add'; 7 | } 8 | 9 | addNewPhone (phone) { 10 | const self = this; 11 | // return promise here to let the phone form controller know the response status 12 | return this.PhoneAPI.addNewPhone(phone) 13 | .then(_success) 14 | .catch(_error); 15 | 16 | function _success () { 17 | self.$state.go('root.layout.phone'); 18 | } 19 | 20 | function _error (message) { 21 | self.Modal.open('Add phone error', message.text, {ok: 'OK'}); 22 | return self.$q.reject(); 23 | } 24 | } 25 | } 26 | 27 | PhoneAddController.$inject = ['PhoneAPI', '$state', '$q', 'Modal']; 28 | 29 | export default PhoneAddController; 30 | -------------------------------------------------------------------------------- /source/app/pages/phone/add/phone-add.controller.spec.js: -------------------------------------------------------------------------------- 1 | import PhoneAddController from './phone-add.controller'; 2 | 3 | describe('PhoneAdd Controller', () => { 4 | let controller; 5 | let PhoneAPI; 6 | let $state; 7 | let $q; 8 | let Modal; 9 | let $rootScope; 10 | 11 | beforeEach(() => { 12 | angular.mock.inject((_$rootScope_, _$q_) => { 13 | $rootScope = _$rootScope_; 14 | $q = _$q_; 15 | PhoneAPI = jasmine.createSpyObj('PhoneAPI', ['addNewPhone']); 16 | $state = jasmine.createSpyObj('$state', ['go']); 17 | Modal = jasmine.createSpyObj('Modal', ['open']); 18 | controller = new PhoneAddController(PhoneAPI, $state, $q, Modal); 19 | }); 20 | }); 21 | 22 | describe('constructor function', () => { 23 | it('should init successfully', () => { 24 | expect(controller.PhoneAPI).toBe(PhoneAPI); 25 | expect(controller.$state).toBe($state); 26 | expect(controller.$q).toBe($q); 27 | expect(controller.Modal).toBe(Modal); 28 | expect(controller.phone).toEqual({}); 29 | expect(controller.state).toEqual('add'); 30 | }); 31 | }); 32 | 33 | describe('addNewPhone function', () => { 34 | let deferred; 35 | 36 | beforeEach(() => { 37 | deferred = $q.defer(); 38 | PhoneAPI.addNewPhone.and.returnValue(deferred.promise); 39 | spyOn($q, 'reject'); 40 | }); 41 | 42 | it('should go to correct state if API resolves', () => { 43 | deferred.resolve(); 44 | controller.addNewPhone({id: 1}); 45 | expect(PhoneAPI.addNewPhone).toHaveBeenCalledWith({id: 1}); 46 | $rootScope.$digest(); 47 | expect($state.go).toHaveBeenCalledWith('root.layout.phone'); 48 | }); 49 | 50 | it('should show error modal if API rejects', () => { 51 | deferred.reject({text: 'error'}); 52 | controller.addNewPhone({id: 1}); 53 | expect(PhoneAPI.addNewPhone).toHaveBeenCalledWith({id: 1}); 54 | $rootScope.$digest(); 55 | expect(Modal.open).toHaveBeenCalledWith('Add phone error', 'error', {ok: 'OK'}); 56 | expect($q.reject).toHaveBeenCalled(); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /source/app/pages/phone/add/phone-add.jade: -------------------------------------------------------------------------------- 1 | - require('./phone-add.styl') 2 | .phone-add-view.full-width 3 | .card 4 | .card-header.p2 5 | h5.title Add a new phone 6 | .card-content 7 | aio-phone-form( 8 | phone="vm.phone", 9 | submit="vm.addNewPhone(phone)", 10 | state="{{vm.state}}" 11 | ) 12 | -------------------------------------------------------------------------------- /source/app/pages/phone/add/phone-add.styl: -------------------------------------------------------------------------------- 1 | @import '../../../components/_common/styles/common' 2 | 3 | .phone-add-view 4 | .card 5 | .card-header 6 | border-bottom 1px solid rgba(0, 0, 0, 0.12) -------------------------------------------------------------------------------- /source/app/pages/phone/detail/phone-detail.controller.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | const originalPhone = Symbol(); 4 | class PhoneDetailController { 5 | constructor (PhoneAPI, $stateParams, $q, Modal) { 6 | Object.assign(this, {PhoneAPI, $stateParams, $q, Modal}); 7 | 8 | this[originalPhone] = {}; 9 | this.state = 'view'; 10 | const id = $stateParams.id; 11 | if (id) { 12 | this._getPhoneDetail(id); 13 | } 14 | } 15 | 16 | _getPhoneDetail (id) { 17 | this.PhoneAPI.getPhoneDetail(id) 18 | .then((data) => { 19 | this.phone = data; 20 | }); 21 | } 22 | 23 | beginEdit () { 24 | this[originalPhone] = angular.copy(this.phone); 25 | this.state = 'edit'; 26 | } 27 | 28 | cancelUpdate () { 29 | this.phone = angular.copy(this[originalPhone]); 30 | this.state = 'view'; 31 | } 32 | 33 | updatePhone (phone) { 34 | const self = this; 35 | // return promise here to let the phone form controller know the response status 36 | return this.PhoneAPI.updatePhone(phone.id, phone) 37 | .then(_success) 38 | .catch(_error); 39 | 40 | function _success (data) { 41 | self.state = 'view'; 42 | self.phone = data; 43 | } 44 | 45 | function _error (message) { 46 | self.Modal.open('Update phone error', message.text, {ok: 'OK'}, () => { 47 | self.cancelUpdate(); 48 | }); 49 | return self.$q.reject(); 50 | } 51 | } 52 | 53 | } 54 | 55 | PhoneDetailController.$inject = ['PhoneAPI', '$stateParams', '$q', 'Modal']; 56 | 57 | export default PhoneDetailController; 58 | -------------------------------------------------------------------------------- /source/app/pages/phone/detail/phone-detail.controller.spec.js: -------------------------------------------------------------------------------- 1 | import PhoneDetailController from './phone-detail.controller'; 2 | 3 | describe('PhoneDetail Controller', () => { 4 | let controller; 5 | let PhoneAPI; 6 | let $stateParams; 7 | let $q; 8 | let Modal; 9 | let $rootScope; 10 | let getPhoneDetailDefer; 11 | let updatePhoneDefer; 12 | 13 | beforeEach(() => { 14 | angular.mock.inject((_$q_, _$rootScope_) => { 15 | $q = _$q_; 16 | $rootScope = _$rootScope_; 17 | PhoneAPI = jasmine.createSpyObj('PhoneAPI', ['getPhoneDetail', 'updatePhone']); 18 | $stateParams = {id: 1}; 19 | Modal = jasmine.createSpyObj('Modal', ['open']); 20 | getPhoneDetailDefer = $q.defer(); 21 | PhoneAPI.getPhoneDetail.and.returnValue(getPhoneDetailDefer.promise); 22 | }); 23 | }); 24 | 25 | describe('constructor function', () => { 26 | function assertCommon () { 27 | expect(controller.PhoneAPI).toBe(PhoneAPI); 28 | expect(controller.$stateParams).toBe($stateParams); 29 | expect(controller.$q).toBe($q); 30 | expect(controller.Modal).toBe(Modal); 31 | expect(controller.state).toEqual('view'); 32 | } 33 | 34 | it('should init successfully with phone id', () => { 35 | $stateParams = {id: 1}; 36 | controller = new PhoneDetailController(PhoneAPI, $stateParams, $q, Modal); 37 | assertCommon(); 38 | expect(PhoneAPI.getPhoneDetail).toHaveBeenCalledWith(1); 39 | }); 40 | 41 | it('should init successfully without phone id', () => { 42 | $stateParams = {}; 43 | controller = new PhoneDetailController(PhoneAPI, $stateParams, $q, Modal); 44 | assertCommon(); 45 | expect(PhoneAPI.getPhoneDetail).not.toHaveBeenCalled(); 46 | }); 47 | }); 48 | 49 | describe('_getPhoneDetail function', () => { 50 | beforeEach(() => { 51 | $stateParams = {id: 1}; 52 | controller = new PhoneDetailController(PhoneAPI, $stateParams, $q, Modal); 53 | }); 54 | 55 | it('should set phone data if API resolves', () => { 56 | getPhoneDetailDefer.resolve('phone'); 57 | $rootScope.$digest(); 58 | expect(controller.phone).toEqual('phone'); 59 | }); 60 | 61 | it('should not set phone data if API rejects', () => { 62 | getPhoneDetailDefer.reject(); 63 | $rootScope.$digest(); 64 | expect(controller.phone).not.toBeDefined(); 65 | }); 66 | }); 67 | 68 | describe('beginEdit/cancelUpdate function', () => { 69 | beforeEach(() => { 70 | controller = new PhoneDetailController(PhoneAPI, $stateParams, $q, Modal); 71 | }); 72 | 73 | it('should set form to correct state', () => { 74 | controller.phone = {id: 1}; 75 | controller.beginEdit(); 76 | expect(controller.state).toEqual('edit'); 77 | controller.phone = {id: 3}; 78 | controller.cancelUpdate(controller); 79 | expect(controller.state).toEqual('view'); 80 | expect(controller.phone).toEqual({id: 1}); 81 | }); 82 | }); 83 | 84 | describe('updatePhone function', () => { 85 | let originalPhone; 86 | let updatedPhone; 87 | 88 | beforeEach(() => { 89 | controller = new PhoneDetailController(PhoneAPI, $stateParams, $q, Modal); 90 | updatePhoneDefer = $q.defer(); 91 | PhoneAPI.updatePhone.and.returnValue(updatePhoneDefer.promise); 92 | originalPhone = {id: 1, model: 'model1', price: 1111}; 93 | updatedPhone = {id: 1, model: 'model2', price: 2222}; 94 | }); 95 | 96 | it('should update phone successfully if API resolves', () => { 97 | updatePhoneDefer.resolve(updatedPhone); 98 | controller.updatePhone(originalPhone); 99 | expect(PhoneAPI.updatePhone).toHaveBeenCalledWith(1, originalPhone); 100 | $rootScope.$digest(); 101 | expect(controller.state).toEqual('view'); 102 | expect(controller.phone).toEqual(updatedPhone); 103 | }); 104 | 105 | it('should not update phone if API rejects', () => { 106 | updatePhoneDefer.reject({text: 'error'}); 107 | spyOn($q, 'reject'); 108 | controller.updatePhone(originalPhone); 109 | expect(PhoneAPI.updatePhone).toHaveBeenCalledWith(1, originalPhone); 110 | $rootScope.$digest(); 111 | expect(controller.phone).not.toBeDefined(); 112 | expect(Modal.open).toHaveBeenCalled(); 113 | expect($q.reject).toHaveBeenCalled(); 114 | const args = Modal.open.calls.argsFor(0); 115 | expect(args[0]).toEqual('Update phone error'); 116 | expect(args[1]).toEqual('error'); 117 | expect(args[2]).toEqual({ok: 'OK'}); 118 | const callback = args[3]; 119 | spyOn(controller, 'cancelUpdate'); 120 | callback(); 121 | expect(controller.cancelUpdate).toHaveBeenCalled(); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /source/app/pages/phone/detail/phone-detail.jade: -------------------------------------------------------------------------------- 1 | - require('./phone-detail.styl') 2 | .phone-detail-view.full-width 3 | .card 4 | .card-header.p2 5 | h5.title {{::vm.phone.model}} 6 | button.btn-floating.blue(ng-show="vm.state === 'view'", ng-click="vm.beginEdit()") 7 | i.mdi-image-edit 8 | .card-content 9 | aio-phone-form( 10 | phone="vm.phone", 11 | state="{{vm.state}}", 12 | submit="vm.updatePhone(phone)", 13 | cancel="vm.cancelUpdate()" 14 | ) 15 | -------------------------------------------------------------------------------- /source/app/pages/phone/detail/phone-detail.styl: -------------------------------------------------------------------------------- 1 | @import '../../../components/_common/styles/common' 2 | 3 | .phone-detail-view 4 | .card 5 | .card-header 6 | border-bottom 1px solid rgba(0, 0, 0, 0.12) 7 | .title 8 | display inline-block 9 | button 10 | float right 11 | margin-top 5px 12 | -------------------------------------------------------------------------------- /source/app/pages/phone/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | import PhoneController from './phone.controller'; 4 | import PhoneAddController from './add/phone-add.controller'; 5 | import PhoneDetailController from './detail/phone-detail.controller'; 6 | import PhoneService from './phone.service'; 7 | 8 | import phoneForm from '../../components/phone-form'; 9 | import phoneTable from '../../components/phone-table'; 10 | 11 | export default angular.module('app.pages.phone', [ 12 | phoneForm.name, 13 | phoneTable.name 14 | ]) 15 | .controller(PhoneController.name, PhoneController) 16 | .controller(PhoneAddController.name, PhoneAddController) 17 | .controller(PhoneDetailController.name, PhoneDetailController) 18 | .service('PhoneAPI', PhoneService); 19 | -------------------------------------------------------------------------------- /source/app/pages/phone/phone.controller.js: -------------------------------------------------------------------------------- 1 | class PhoneController { 2 | constructor (PhoneAPI, $state, Modal) { 3 | Object.assign(this, {PhoneAPI, $state, Modal}); 4 | 5 | this._getPhoneList(); 6 | } 7 | 8 | _getPhoneList () { 9 | this.PhoneAPI.getPhones() 10 | .then((data) => { 11 | this.phones = data; 12 | }); 13 | } 14 | 15 | gotoPhoneDetail (phone) { 16 | this.$state.go('root.layout.phone.detail', {id: phone.id}); 17 | } 18 | 19 | deletePhone (phone) { 20 | this.Modal.open( 21 | 'Are your sure?', 22 | `All information about [${phone.model}] will be REMOVED!`, 23 | {ok: 'delete', cancel: 'cancel'}, 24 | (answer) => { 25 | if (answer) { 26 | this._doDelete(phone.id); 27 | } 28 | } 29 | ); 30 | } 31 | 32 | _doDelete (id) { 33 | const self = this; 34 | this.PhoneAPI.removePhone(id) 35 | .then(_success) 36 | .catch(_error); 37 | 38 | function _success () { 39 | self._getPhoneList(); 40 | } 41 | 42 | function _error (message) { 43 | self.Modal.open( 44 | 'Delete phone error', 45 | message.text, 46 | {ok: 'OK'}, 47 | (answer) => { 48 | if (answer) { 49 | self._getPhoneList(); 50 | } 51 | } 52 | ); 53 | } 54 | } 55 | } 56 | 57 | PhoneController.$inject = ['PhoneAPI', '$state', 'Modal']; 58 | 59 | export default PhoneController; 60 | -------------------------------------------------------------------------------- /source/app/pages/phone/phone.controller.spec.js: -------------------------------------------------------------------------------- 1 | import PhoneController from './phone.controller'; 2 | 3 | describe('Phone Controller', () => { 4 | let controller; 5 | let PhoneAPI; 6 | let $state; 7 | let Modal; 8 | let $q; 9 | let $rootScope; 10 | let getPhonesDefer; 11 | let removePhoneDefer; 12 | 13 | beforeEach(() => { 14 | angular.mock.inject((_$q_, _$rootScope_) => { 15 | $q = _$q_; 16 | $rootScope = _$rootScope_; 17 | PhoneAPI = jasmine.createSpyObj('PhoneAPI', ['getPhones', 'removePhone']); 18 | $state = jasmine.createSpyObj('$state', ['go']); 19 | Modal = jasmine.createSpyObj('Modal', ['open']); 20 | getPhonesDefer = $q.defer(); 21 | PhoneAPI.getPhones.and.returnValue(getPhonesDefer.promise); 22 | removePhoneDefer = $q.defer(); 23 | PhoneAPI.removePhone.and.returnValue(removePhoneDefer.promise); 24 | controller = new PhoneController(PhoneAPI, $state, Modal); 25 | }); 26 | }); 27 | 28 | describe('constructor function', () => { 29 | it('should init successfully', () => { 30 | expect(controller.PhoneAPI).toBe(PhoneAPI); 31 | expect(controller.$state).toBe($state); 32 | expect(controller.Modal).toBe(Modal); 33 | }); 34 | }); 35 | 36 | describe('_getPhoneList function', () => { 37 | it('should set phone data if API resolves', () => { 38 | getPhonesDefer.resolve('phone'); 39 | controller._getPhoneList(); 40 | $rootScope.$digest(); 41 | expect(controller.phones).toEqual('phone'); 42 | }); 43 | 44 | it('should not set phone data if API rejects', () => { 45 | getPhonesDefer.reject(); 46 | controller._getPhoneList(); 47 | $rootScope.$digest(); 48 | expect(controller.phones).not.toBeDefined(); 49 | }); 50 | }); 51 | 52 | describe('gotoPhoneDetail function', () => { 53 | it('should go to correct state', () => { 54 | controller.gotoPhoneDetail({id: 1}); 55 | expect($state.go).toHaveBeenCalledWith('root.layout.phone.detail', {id: 1}); 56 | }); 57 | }); 58 | 59 | describe('deletePhone function', () => { 60 | beforeEach(() => { 61 | spyOn(controller, '_doDelete'); 62 | }); 63 | 64 | it('should show error modal before deleting', () => { 65 | controller.deletePhone({ 66 | id: 1, 67 | model: 'model' 68 | }); 69 | expect(Modal.open).toHaveBeenCalled(); 70 | const args = Modal.open.calls.argsFor(0); 71 | expect(args[0]).toEqual('Are your sure?'); 72 | expect(args[1]).toEqual('All information about [model] will be REMOVED!'); 73 | expect(args[2]).toEqual({ok: 'delete', cancel: 'cancel'}); 74 | const callback = args[3]; 75 | callback(true); 76 | expect(controller._doDelete).toHaveBeenCalledWith(1); 77 | controller._doDelete.calls.reset(); 78 | callback(false); 79 | expect(controller._doDelete).not.toHaveBeenCalled(); 80 | }); 81 | }); 82 | 83 | describe('_doDelete function', () => { 84 | beforeEach(() => { 85 | spyOn(controller, '_getPhoneList'); 86 | }); 87 | 88 | it('should refresh phone list after removing successfully', () => { 89 | removePhoneDefer.resolve(); 90 | controller._doDelete(1); 91 | expect(PhoneAPI.removePhone).toHaveBeenCalledWith(1); 92 | $rootScope.$digest(); 93 | expect(controller._getPhoneList).toHaveBeenCalled(); 94 | expect(Modal.open).not.toHaveBeenCalled(); 95 | }); 96 | 97 | it('should show error modal after removing failed', () => { 98 | removePhoneDefer.reject({text: 'error'}); 99 | controller._doDelete(1); 100 | expect(PhoneAPI.removePhone).toHaveBeenCalledWith(1); 101 | $rootScope.$digest(); 102 | expect(Modal.open).toHaveBeenCalled(); 103 | const args = Modal.open.calls.argsFor(0); 104 | expect(args[0]).toEqual('Delete phone error'); 105 | expect(args[1]).toEqual('error'); 106 | expect(args[2]).toEqual({ok: 'OK'}); 107 | const callback = args[3]; 108 | callback(true); 109 | expect(controller._getPhoneList).toHaveBeenCalled(); 110 | controller._getPhoneList.calls.reset(); 111 | callback(false); 112 | expect(controller._getPhoneList).not.toHaveBeenCalled(); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /source/app/pages/phone/phone.jade: -------------------------------------------------------------------------------- 1 | .phone-main-view.full-width 2 | a.btn-add-new.btn.btn-large.green.waves-effect.waves-light(ui-sref="root.layout.phone.add") 3 | i.mdi-content-add-box.left 4 | | Add New 5 | aio-phone-table( 6 | phones="vm.phones", 7 | first-button-click="vm.gotoPhoneDetail(phone)", 8 | second-button-click="vm.deletePhone(phone)" 9 | ) 10 | -------------------------------------------------------------------------------- /source/app/pages/phone/phone.route.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | appPhoneRun.$inject = ['RouterHelper']; 4 | function appPhoneRun (RouterHelper) { 5 | RouterHelper.configureStates(getStates()); 6 | } 7 | 8 | function getStates () { 9 | return [ 10 | { 11 | state: 'root.layout.phone', 12 | config: { 13 | url: '/phone', 14 | views: { 15 | 'main@root': { 16 | templateProvider: ['$q', ($q) => { 17 | return $q((resolve) => { 18 | require.ensure([], () => { 19 | resolve(require('./phone.jade')); 20 | }, 'phone'); 21 | }); 22 | }], 23 | controller: 'PhoneController as vm' 24 | } 25 | }, 26 | data: { 27 | title: 'Phone', 28 | _class: 'phone', 29 | requireLogin: true 30 | }, 31 | sidebar: { 32 | icon: 'mdi-hardware-phone-android', 33 | text: 'Phones' 34 | }, 35 | breadcrumb: 'Phone List', 36 | resolve: { 37 | loadModule: ['$q', '$ocLazyLoad', ($q, $ocLazyLoad) => { 38 | return $q((resolve) => { 39 | require.ensure([], () => { 40 | $ocLazyLoad.load({name: require('./index').name}); 41 | resolve(); 42 | }, 'phone'); 43 | }); 44 | }] 45 | } 46 | } 47 | }, 48 | { 49 | state: 'root.layout.phone.add', 50 | config: { 51 | url: '/add', 52 | views: { 53 | 'main@root': { 54 | templateProvider: ['$q', ($q) => { 55 | return $q((resolve) => { 56 | require.ensure([], () => { 57 | resolve(require('./add/phone-add.jade')); 58 | }, 'phone'); 59 | }); 60 | }], 61 | controller: 'PhoneAddController as vm' 62 | } 63 | }, 64 | breadcrumb: 'Add Phone' 65 | } 66 | }, 67 | { 68 | state: 'root.layout.phone.detail', 69 | config: { 70 | url: '/:id', 71 | views: { 72 | 'main@root': { 73 | templateProvider: ['$q', ($q) => { 74 | return $q((resolve) => { 75 | require.ensure([], () => { 76 | resolve(require('./detail/phone-detail.jade')); 77 | }, 'phone'); 78 | }); 79 | }], 80 | controller: 'PhoneDetailController as vm' 81 | } 82 | }, 83 | breadcrumb: 'Phone Detail' 84 | } 85 | } 86 | ]; 87 | } 88 | 89 | export default angular.module('app.routes.phone', []) 90 | .run(appPhoneRun); 91 | -------------------------------------------------------------------------------- /source/app/pages/phone/phone.service.js: -------------------------------------------------------------------------------- 1 | const errorHandler = Symbol(); 2 | class PhoneService { 3 | constructor ($http, $q, AjaxError) { 4 | Object.assign(this, {$http, $q, AjaxError}); 5 | 6 | this[errorHandler] = this.AjaxError.catcher.bind(this.AjaxError); 7 | } 8 | 9 | getPhones () { 10 | const self = this; 11 | return this.$http.get('api/phones') 12 | .then(_success) 13 | .catch(this[errorHandler]); 14 | 15 | function _success (response) { 16 | const data = response.data; 17 | if (response.status === 200 && data.code === 0) { 18 | return data.result.phones; 19 | } 20 | return self.$q.reject(data.message); 21 | } 22 | } 23 | 24 | getPhoneDetail (id) { 25 | const self = this; 26 | return this.$http.get(`api/phones/${id}`) 27 | .then(_success) 28 | .catch(this[errorHandler]); 29 | 30 | function _success (response) { 31 | const data = response.data; 32 | if (response.status === 200 && data.code === 0) { 33 | return data.result.phone; 34 | } 35 | return self.$q.reject(data.message); 36 | } 37 | } 38 | 39 | addNewPhone (phone) { 40 | const self = this; 41 | const req = { 42 | phone 43 | }; 44 | return this.$http.post('api/phones', req) 45 | .then(_success) 46 | .catch(this[errorHandler]); 47 | 48 | function _success (response) { 49 | const data = response.data; 50 | if (response.status === 200 && data.code === 0) { 51 | return data.result.phone; 52 | } 53 | return self.$q.reject(data.message); 54 | } 55 | } 56 | 57 | updatePhone (id, phone) { 58 | const self = this; 59 | const req = { 60 | phone 61 | }; 62 | return this.$http.put(`api/phones/${id}`, req) 63 | .then(_success) 64 | .catch(this[errorHandler]); 65 | 66 | function _success (response) { 67 | const data = response.data; 68 | if (response.status === 200 && data.code === 0) { 69 | return data.result.phone; 70 | } 71 | return self.$q.reject(data.message); 72 | } 73 | } 74 | 75 | removePhone (id) { 76 | const self = this; 77 | return this.$http.delete(`api/phones/${id}`) 78 | .then(_success) 79 | .catch(this[errorHandler]); 80 | 81 | function _success (response) { 82 | const data = response.data; 83 | if (response.status === 200 && data.code === 0) { 84 | return data.result.phone; 85 | } 86 | return self.$q.reject(data.message); 87 | } 88 | } 89 | } 90 | 91 | PhoneService.$inject = ['$http', '$q', 'AjaxErrorHandler']; 92 | 93 | export default PhoneService; 94 | -------------------------------------------------------------------------------- /source/test/e2e/mocks/e2e.config.js: -------------------------------------------------------------------------------- 1 | appTestConfig.$inject = ['$httpProvider']; 2 | function appTestConfig ($httpProvider) { 3 | $httpProvider.interceptors.push(apiDelayInterceptor); 4 | 5 | apiDelayInterceptor.$inject = ['$timeout', '$q']; 6 | function apiDelayInterceptor ($timeout, $q) { 7 | return { 8 | response (response) { 9 | // all API response will be delayed 1s to simulate real network 10 | const delay = 1000; 11 | if (response.config.url.match(/^api\//)) { 12 | const d = $q.defer(); 13 | $timeout(() => { 14 | d.resolve(response); 15 | }, delay); 16 | return d.promise; 17 | } 18 | return response; 19 | } 20 | }; 21 | } 22 | } 23 | 24 | export default appTestConfig; 25 | -------------------------------------------------------------------------------- /source/test/e2e/mocks/e2e.data.js: -------------------------------------------------------------------------------- 1 | // all data used by e2e mock service is here 2 | class MockData { 3 | constructor () { 4 | // private 5 | this.userInfo = { 6 | name: 'PinkyJie' 7 | }; 8 | this.userProducts = [ 9 | { 10 | name: 'phone', 11 | count: 5, 12 | icon: 'mdi-hardware-phone-android' 13 | } 14 | ]; 15 | this.phones = [ 16 | { 17 | id: '1', 18 | model: 'iPhone 6', 19 | os: 'iOS', 20 | price: 5288, 21 | manufacturer: 'Apple', 22 | size: 4.7, 23 | releaseDate: _getTimestamp(2014, 10, 9) 24 | }, 25 | { 26 | id: '2', 27 | model: 'iPhone 6 Plus', 28 | os: 'iOS', 29 | price: 6088, 30 | size: 5.5, 31 | manufacturer: 'Apple', 32 | releaseDate: _getTimestamp(2014, 10, 9) 33 | }, 34 | { 35 | id: '3', 36 | model: 'Nexus 6', 37 | os: 'Android', 38 | price: 4400, 39 | size: 5.96, 40 | manufacturer: 'Motorola', 41 | releaseDate: _getTimestamp(2014, 10, 10) 42 | }, 43 | { 44 | id: '4', 45 | model: 'Galaxy S6', 46 | os: 'Android', 47 | price: 5288, 48 | size: 5.1, 49 | manufacturer: 'Samsung', 50 | releaseDate: _getTimestamp(2015, 3, 25) 51 | }, 52 | { 53 | id: '5', 54 | model: 'Mi Note', 55 | os: 'Android', 56 | price: 2299, 57 | size: 5.7, 58 | manufacturer: 'Xiaomi', 59 | releaseDate: _getTimestamp(2015, 1, 18) 60 | } 61 | ]; 62 | } 63 | } 64 | 65 | MockData.$inject = []; 66 | 67 | function _getTimestamp (year, month, day) { 68 | const date = new Date(); 69 | date.setFullYear(year); 70 | date.setMonth(month - 1); 71 | date.setDate(day); 72 | date.setHours(0); 73 | date.setMinutes(0); 74 | date.setSeconds(0); 75 | return date; 76 | } 77 | 78 | export default MockData; 79 | -------------------------------------------------------------------------------- /source/test/e2e/mocks/e2e.phone.js: -------------------------------------------------------------------------------- 1 | // API mock for phone.service.js 2 | phoneServiceMock.$inject = ['MockData', '$httpBackend']; 3 | function phoneServiceMock (MockData, $httpBackend) { 4 | $httpBackend.whenGET('api/phones').respond(getPhonesHandler); 5 | $httpBackend.whenGET(/api\/phones\/\d+/).respond(getPhoneDetailHandler); 6 | $httpBackend.whenPOST('api/phones').respond(addNewPhoneHandler); 7 | $httpBackend.whenPUT(/api\/phones\/\d+/).respond(updatePhoneDetailHandler); 8 | $httpBackend.whenDELETE(/api\/phones\/\d+/).respond(removePhoneHandler); 9 | 10 | function getPhonesHandler () { 11 | return [200, {code: 0, message: null, result: { 12 | phones: MockData.phones 13 | }}]; 14 | } 15 | 16 | function getPhoneDetailHandler (method, url) { 17 | const matches = url.match(/^api\/phones\/(\d+)/); 18 | let id; 19 | let targetPhone; 20 | if (matches.length === 2) { 21 | id = matches[1]; 22 | targetPhone = _getPhoneById(id); 23 | if (targetPhone.length > 0) { 24 | return [200, {code: 0, message: null, result: { 25 | phone: targetPhone[0] 26 | }}]; 27 | } 28 | } 29 | return [200, {code: 1, message: 'PHONE_QUERY_NOT_FOUND', result: null}]; 30 | } 31 | 32 | function addNewPhoneHandler (method, url, data) { 33 | const req = JSON.parse(data); 34 | const currentCount = MockData.phones.length; 35 | req.phone.id = String(currentCount + 1); 36 | MockData.phones.push(req.phone); 37 | return [200, {code: 0, message: null, result: { 38 | phone: req.phone 39 | }}]; 40 | } 41 | 42 | function updatePhoneDetailHandler (method, url, data) { 43 | const req = JSON.parse(data); 44 | const matches = url.match(/^api\/phones\/(\d+)/); 45 | let id; 46 | let targetPhone; 47 | let index; 48 | if (matches.length === 2) { 49 | id = matches[1]; 50 | targetPhone = _getPhoneById(id); 51 | if (targetPhone.length > 0) { 52 | MockData.phones.forEach((phone, idx) => { 53 | if (phone.id === id) { 54 | index = idx; 55 | } 56 | }); 57 | req.phone.id = id; 58 | MockData.phones[index] = req.phone; 59 | return [200, {code: 0, message: null, result: { 60 | phone: req.phone 61 | }}]; 62 | } 63 | } 64 | return [200, {code: 1, message: 'PHONE_UPDATE_NOT_FOUND', result: null}]; 65 | } 66 | 67 | function removePhoneHandler (method, url) { 68 | const matches = url.match(/^api\/phones\/(\d+)/); 69 | let id; 70 | let targetPhone; 71 | let index; 72 | if (matches.length === 2) { 73 | id = matches[1]; 74 | targetPhone = _getPhoneById(id); 75 | if (targetPhone.length > 0) { 76 | MockData.phones.forEach((phone, idx) => { 77 | if (phone.id === id) { 78 | index = idx; 79 | } 80 | }); 81 | MockData.phones.splice(index, 1); 82 | return [200, {code: 0, message: null, result: { 83 | phone: targetPhone[0] 84 | }}]; 85 | } 86 | } 87 | return [200, {code: 1, message: 'PHONE_DELETE_NOT_FOUND', result: null}]; 88 | } 89 | 90 | function _getPhoneById (id) { 91 | return MockData.phones.filter((phone) => { 92 | return phone.id === id; 93 | }); 94 | } 95 | } 96 | 97 | export default phoneServiceMock; 98 | -------------------------------------------------------------------------------- /source/test/e2e/mocks/e2e.user.js: -------------------------------------------------------------------------------- 1 | // API mock for user.service.js 2 | userServiceMock.$inject = ['MockData', '$httpBackend']; 3 | function userServiceMock (MockData, $httpBackend) { 4 | $httpBackend.whenGET('api/user/loginstatus').respond(loginStatusHandler); 5 | $httpBackend.whenPOST('api/user/login').respond(loginHandler); 6 | $httpBackend.whenPOST('api/user/logout').respond(logoutHandler); 7 | $httpBackend.whenGET('api/user/products').respond(productsHandler); 8 | 9 | function loginStatusHandler () { 10 | const code = MockData.loginStatus ? 0 : 1; 11 | const result = MockData.loginStatus ? {user: MockData.userInfo} : null; 12 | return [200, {code, message: null, result}]; 13 | } 14 | 15 | function loginHandler (method, url, data) { 16 | const req = JSON.parse(data); 17 | if (req.email === 'error@error.com') { 18 | return [200, { 19 | code: 1, 20 | message: 'LOGIN_WRONG_EMAIL_PASSWORD_PAIR', 21 | result: null 22 | }]; 23 | } else if (req.email === 'lock@lock.com') { 24 | return [200, { 25 | code: 1, 26 | message: 'LOGIN_USER_IN_LOCK', 27 | result: null 28 | }]; 29 | } 30 | MockData.loginStatus = true; 31 | return [200, { 32 | code: 0, message: null, 33 | result: { 34 | user: MockData.userInfo 35 | } 36 | }]; 37 | } 38 | 39 | function logoutHandler () { 40 | MockData.loginStatus = false; 41 | return [200, {code: 0, message: null, result: null}]; 42 | } 43 | 44 | function productsHandler () { 45 | return [200, {code: 0, message: null, result: { 46 | summary: MockData.userProducts 47 | }}]; 48 | } 49 | } 50 | 51 | export default userServiceMock; 52 | -------------------------------------------------------------------------------- /source/test/e2e/mocks/index.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import 'angular-mocks'; 3 | 4 | import app from '../../../app'; 5 | 6 | import MockData from './e2e.data'; 7 | import appTestConfig from './e2e.config'; 8 | import userServiceMock from './e2e.user'; 9 | import phoneServiceMock from './e2e.phone'; 10 | 11 | angular.module('appTest', [ 12 | app.name, 13 | 'ngMockE2E' 14 | ]) 15 | .service(MockData.name, MockData) 16 | .config(appTestConfig) 17 | .run(userServiceMock) 18 | .run(phoneServiceMock); 19 | -------------------------------------------------------------------------------- /source/test/e2e/specs/404.spec.js: -------------------------------------------------------------------------------- 1 | import NotFoundPage from './page-objects/404.page'; 2 | 3 | describe('404 Page:', () => { 4 | let page; 5 | beforeEach(() => { 6 | page = new NotFoundPage(); 7 | page.load(); 8 | }); 9 | 10 | describe('Layout:', () => { 11 | it('should have correct layout', () => { 12 | const config = { 13 | url: '404', 14 | title: '404', 15 | klass: 'notfound', 16 | header: 'prelogin', 17 | sidebar: false, 18 | breadcrumb: { 19 | items: [ 20 | { 21 | link: false, 22 | text: '404' 23 | } 24 | ] 25 | } 26 | }; 27 | page.assertCorrectLayout(config); 28 | }); 29 | }); 30 | 31 | describe('Main section:', () => { 32 | it('should display 404 text', () => { 33 | expect(page.ele.text.getText()).toEqual('404 Not Found'); 34 | }); 35 | }); 36 | 37 | it('should redirect user to 404 with non-exist URL', () => { 38 | browser._.gotoUrl('aaa'); 39 | browser._.expectUrlToMatch('404'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /source/test/e2e/specs/dashboard.spec.js: -------------------------------------------------------------------------------- 1 | import DashboardPage from './page-objects/dashboard.page'; 2 | 3 | describe('Dashboard Page:', () => { 4 | let page; 5 | beforeEach(() => { 6 | page = new DashboardPage(); 7 | page.load(); 8 | }); 9 | 10 | describe('Layout:', () => { 11 | it('should have correct layout', () => { 12 | const config = { 13 | url: 'dashboard', 14 | title: 'Dashboard', 15 | klass: 'dashboard', 16 | header: 'login', 17 | sidebar: { 18 | items: [ 19 | { 20 | link: 'dashboard', 21 | active: true, 22 | icon: 'mdi-action-dashboard', 23 | text: 'DASHBOARD' 24 | }, 25 | { 26 | link: 'phone', 27 | active: false, 28 | icon: 'mdi-hardware-phone-android', 29 | text: 'PHONES' 30 | } 31 | ] 32 | }, 33 | breadcrumb: { 34 | items: [ 35 | { 36 | link: false, 37 | text: 'Dashboard' 38 | } 39 | ] 40 | } 41 | }; 42 | page.assertCorrectLayout(config); 43 | }); 44 | }); 45 | 46 | describe('Banner section:', () => { 47 | it('should have correct banner text', () => { 48 | expect(page.ele.bannerText.getText()).toEqual('Welcome PinkyJie!'); 49 | }); 50 | }); 51 | 52 | describe('Menu Box section:', () => { 53 | it('should display correct menu box', () => { 54 | const colors = ['indigo', 'red', 'pink']; 55 | const boxes = page.ele.menuBox; 56 | const expectedMenus = [ 57 | { 58 | icon: 'mdi-hardware-phone-android', 59 | count: '5', 60 | name: 'PHONE', 61 | link: 'phone' 62 | } 63 | ]; 64 | expect(boxes.view.count()).toBe(1); 65 | boxes.view.each((box, index) => { 66 | const expectedMenu = expectedMenus[index]; 67 | expect(box).toHaveClass(colors[index % colors.length]); 68 | expect(box.getAttribute('href')).toEqual(`${browser.baseUrl}/${expectedMenu.link}`); 69 | expect(box.$(boxes.icon)).toHaveClass(expectedMenu.icon); 70 | expect(box.$(boxes.name).getText()).toEqual(expectedMenu.name); 71 | expect(box.$(boxes.count).getText()).toEqual(expectedMenu.count); 72 | }); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /source/test/e2e/specs/home.spec.js: -------------------------------------------------------------------------------- 1 | import HomePage from './page-objects/home.page'; 2 | 3 | describe('Home Page:', () => { 4 | let page; 5 | beforeEach(() => { 6 | page = new HomePage(); 7 | page.load(); 8 | }); 9 | 10 | describe('Layout:', () => { 11 | it('should have correct layout', () => { 12 | const config = { 13 | url: '/', 14 | title: 'Home', 15 | klass: 'home', 16 | header: 'prelogin', 17 | sidebar: false, 18 | breadcrumb: false 19 | }; 20 | page.assertCorrectLayout(config); 21 | }); 22 | }); 23 | 24 | describe('Hero section:', () => { 25 | it('should display correct title and sub title', () => { 26 | expect(page.ele.mainTitle.getText()).toEqual('Aio Angular App'); 27 | expect(page.ele.subTitle.getText()).toEqual( 28 | 'Awesome web app built on AngularJS & Material Design.'); 29 | }); 30 | 31 | it('should go to login page if click get started', () => { 32 | page.ele.getStartedBtn.click(); 33 | browser._.expectUrlToMatch('login'); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /source/test/e2e/specs/login.spec.js: -------------------------------------------------------------------------------- 1 | import LoginPage from './page-objects/login.page'; 2 | 3 | describe('Login Page:', () => { 4 | let page; 5 | beforeEach(() => { 6 | page = new LoginPage(); 7 | page.load(); 8 | }); 9 | 10 | describe('Layout:', () => { 11 | it('should have correct layout', () => { 12 | const config = { 13 | url: 'login', 14 | title: 'Login', 15 | klass: 'login', 16 | header: 'prelogin', 17 | sidebar: false, 18 | breadcrumb: false 19 | }; 20 | page.assertCorrectLayout(config); 21 | }); 22 | }); 23 | 24 | describe('Login form section:', () => { 25 | it('should login successfully with correct credential', () => { 26 | expect(page.ele.loginBtn.isEnabled()).toBe(false); 27 | page.loginWithCredential('f@f', 'f'); 28 | browser._.expectUrlToMatch(page.urlAfterLogin); 29 | }); 30 | 31 | it('should display error message with incorrect credential', () => { 32 | expect(page.ele.loginMessage.isPresent()).toBe(false); 33 | page.loginWithCredential('error@error.com', 'f'); 34 | expect(page.ele.loginMessage.isDisplayed()).toBe(true); 35 | expect(page.ele.loginMessage.getText()) 36 | .toEqual('Incorrect email or password, please try again!'); 37 | }); 38 | 39 | it('should display error message with locked account', () => { 40 | expect(page.ele.loginMessage.isPresent()).toBe(false); 41 | page.loginWithCredential('lock@lock.com', 'f'); 42 | expect(page.ele.loginMessage.isDisplayed()).toBe(true); 43 | expect(page.ele.loginMessage.getText()) 44 | .toEqual('Your account is locked!'); 45 | }); 46 | 47 | it('should automatically log user to dashboard if user has already logged in', () => { 48 | page.loginWithCredential('f@f', 'f'); 49 | browser._.expectUrlToMatch(page.urlAfterLogin); 50 | // go back to home page 51 | page.getHeader().title.click(); 52 | page.homePage.ele.getStartedBtn.click(); 53 | // go to login page will redirect user to dashboard 54 | browser._.expectUrlToMatch(page.urlAfterLogin); 55 | }); 56 | }); 57 | }); 58 | 59 | describe('Logout Page:', () => { 60 | let page; 61 | beforeEach(() => { 62 | page = new LoginPage(); 63 | browser._.gotoUrl(page.logoutUrl); 64 | }); 65 | 66 | it('should not display loading view', () => { 67 | expect(page.ele.loadingView.isPresent()).toBe(false); 68 | }); 69 | 70 | it('should display success logout message', () => { 71 | expect(page.ele.loginMessage.isDisplayed()).toBe(true); 72 | expect(page.ele.loginMessage.getText()) 73 | .toEqual('You have been successfully logged out!'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /source/test/e2e/specs/page-objects/404.page.js: -------------------------------------------------------------------------------- 1 | // 404 page object 2 | class NotFoundPage extends browser._BasePageObject { 3 | constructor () { 4 | super('404'); 5 | } 6 | 7 | _getAllElements () { 8 | const $page = $('.not-found-view'); 9 | return { 10 | text: $page.$('h2') 11 | }; 12 | } 13 | } 14 | 15 | export default NotFoundPage; 16 | -------------------------------------------------------------------------------- /source/test/e2e/specs/page-objects/dashboard.page.js: -------------------------------------------------------------------------------- 1 | import LoginPage from './login.page'; 2 | 3 | // dashboard page object 4 | class DashboardPage extends browser._BasePageObject { 5 | constructor () { 6 | super('dashboard'); 7 | } 8 | 9 | _getAllElements () { 10 | const $page = $('.dashboard-view'); 11 | const bannerClass = '.banner-view'; 12 | const menuBoxClass = '.square-menu-view'; 13 | return { 14 | banner: $page.$(bannerClass), 15 | bannerText: $page.$(`${bannerClass} p`), 16 | menuBoxes: $page.$(menuBoxClass), 17 | menuBox: { 18 | view: $page.$$(`${menuBoxClass} > .box`), 19 | icon: 'i', 20 | name: '.name', 21 | count: '.count' 22 | } 23 | }; 24 | } 25 | 26 | // overrite load function to support login 27 | load () { 28 | super.load(); 29 | const loginPage = new LoginPage(); 30 | browser._.expectUrlToMatch(loginPage.url); 31 | loginPage.loginWithCredential('f@f', 'f'); 32 | browser._.expectUrlToMatch(this.url); 33 | } 34 | } 35 | 36 | export default DashboardPage; 37 | -------------------------------------------------------------------------------- /source/test/e2e/specs/page-objects/home.page.js: -------------------------------------------------------------------------------- 1 | // home page object 2 | class HomePage extends browser._BasePageObject { 3 | constructor () { 4 | super(''); 5 | } 6 | 7 | _getAllElements () { 8 | const $page = $('.home-view'); 9 | return { 10 | mainTitle: $page.$('.title'), 11 | subTitle: $page.$('.subtitle'), 12 | getStartedBtn: $page.$('.btn-get-started') 13 | }; 14 | } 15 | } 16 | 17 | export default HomePage; 18 | -------------------------------------------------------------------------------- /source/test/e2e/specs/page-objects/login.page.js: -------------------------------------------------------------------------------- 1 | import HomePage from './home.page'; 2 | 3 | // login page object 4 | class LoginPage extends browser._BasePageObject { 5 | constructor () { 6 | super('login'); 7 | this.urlAfterLogin = 'dashboard'; 8 | this.logoutUrl = 'login?action=logout'; 9 | this.homePage = new HomePage(); 10 | } 11 | 12 | _getAllElements () { 13 | const $page = $('.login-view'); 14 | return { 15 | loadingView: $page.$('.login-checking'), 16 | loginMessage: $page.$('.login-message > p'), 17 | emailInput: $page.element(by.model('form.credential.email')), 18 | passwordInput: $page.element(by.model('form.credential.password')), 19 | loginBtn: $page.$('.btn-login') 20 | }; 21 | } 22 | 23 | loginWithCredential (email, password) { 24 | this.ele.emailInput.sendKeys(email); 25 | this.ele.passwordInput.sendKeys(password); 26 | expect(this.ele.loginBtn.isEnabled()).toBe(true); 27 | this.ele.loginBtn.click(); 28 | } 29 | } 30 | 31 | export default LoginPage; 32 | -------------------------------------------------------------------------------- /source/test/e2e/specs/page-objects/phone-add.page.js: -------------------------------------------------------------------------------- 1 | import LoginPage from './login.page'; 2 | import PhoneFormComp from './phone-form.comp'; 3 | import PhonePage from './phone-main.page'; 4 | 5 | // phone add page object 6 | class PhoneAddPage extends browser._BasePageObject { 7 | constructor () { 8 | super('phone/add'); 9 | this.data = { 10 | phone: { 11 | Model: '', 12 | OS: 'Choose OS Type', 13 | Price: '', 14 | 'Screen Size': '', 15 | Manufacturer: '', 16 | 'Release Date': '', 17 | date: '' 18 | } 19 | }; 20 | this.phonePage = new PhonePage(); 21 | } 22 | 23 | _getAllElements () { 24 | const $page = $('.phone-add-view'); 25 | const $header = $page.$('.card-header'); 26 | return { 27 | title: $header.$('.title'), 28 | form: new PhoneFormComp($page) 29 | }; 30 | } 31 | 32 | // overrite load function to support login 33 | load () { 34 | super.load(); 35 | const loginPage = new LoginPage(); 36 | browser._.expectUrlToMatch(loginPage.url); 37 | loginPage.loginWithCredential('f@f', 'f'); 38 | browser._.expectUrlToMatch(this.url); 39 | } 40 | } 41 | 42 | export default PhoneAddPage; 43 | -------------------------------------------------------------------------------- /source/test/e2e/specs/page-objects/phone-detail.page.js: -------------------------------------------------------------------------------- 1 | import LoginPage from './login.page'; 2 | import PhoneFormComp from './phone-form.comp'; 3 | 4 | // phone detail page object 5 | class PhoneDetailPage extends browser._BasePageObject { 6 | constructor () { 7 | super('phone/1'); 8 | this.data = { 9 | phone: { 10 | Model: 'iPhone 6', 11 | OS: 'iOS', 12 | Price: '5288', 13 | 'Screen Size': '4.7', 14 | Manufacturer: 'Apple', 15 | 'Release Date': 'October 9, 2014', 16 | date: '2014-10-9' 17 | } 18 | }; 19 | } 20 | 21 | _getAllElements () { 22 | const $page = $('.phone-detail-view'); 23 | const $header = $page.$('.card-header'); 24 | return { 25 | title: $header.$('.title'), 26 | editBtn: $header.$('button'), 27 | form: new PhoneFormComp($page) 28 | }; 29 | } 30 | 31 | // overrite load function to support login 32 | load () { 33 | super.load(); 34 | const loginPage = new LoginPage(); 35 | browser._.expectUrlToMatch(loginPage.url); 36 | loginPage.loginWithCredential('f@f', 'f'); 37 | browser._.expectUrlToMatch(this.url); 38 | } 39 | } 40 | 41 | export default PhoneDetailPage; 42 | -------------------------------------------------------------------------------- /source/test/e2e/specs/page-objects/phone-main.page.js: -------------------------------------------------------------------------------- 1 | import LoginPage from './login.page'; 2 | 3 | // phone page object 4 | class PhonePage extends browser._BasePageObject { 5 | constructor () { 6 | super('phone'); 7 | } 8 | 9 | _getAllElements () { 10 | const $page = $('.phone-main-view'); 11 | return { 12 | addNewBtn: $page.$('.btn-add-new'), 13 | tableHeader: $page.$$('.heading th'), 14 | phoneItem: { 15 | view: $page.$$('.phone-item'), 16 | cell: 'td', 17 | firstBtn: '.btn-first', 18 | secondBtn: '.btn-second', 19 | icon: 'i' 20 | } 21 | }; 22 | } 23 | 24 | // overrite load function to support login 25 | load () { 26 | super.load(); 27 | const loginPage = new LoginPage(); 28 | browser._.expectUrlToMatch(loginPage.url); 29 | loginPage.loginWithCredential('f@f', 'f'); 30 | browser._.expectUrlToMatch(this.url); 31 | } 32 | } 33 | 34 | export default PhonePage; 35 | -------------------------------------------------------------------------------- /source/test/e2e/specs/phone-add.spec.js: -------------------------------------------------------------------------------- 1 | import PhoneAddPage from './page-objects/phone-add.page'; 2 | 3 | describe('Phone Add Page:', () => { 4 | let page; 5 | beforeEach(() => { 6 | page = new PhoneAddPage(); 7 | page.load(); 8 | }); 9 | 10 | describe('Layout:', () => { 11 | it('should have correct layout', () => { 12 | const config = { 13 | url: 'phone/add', 14 | title: 'Phone', 15 | klass: 'phone', 16 | header: 'login', 17 | sidebar: { 18 | items: [ 19 | { 20 | link: 'dashboard', 21 | active: false, 22 | icon: 'mdi-action-dashboard', 23 | text: 'DASHBOARD' 24 | }, 25 | { 26 | link: 'phone', 27 | active: true, 28 | icon: 'mdi-hardware-phone-android', 29 | text: 'PHONES' 30 | } 31 | ] 32 | }, 33 | breadcrumb: { 34 | items: [ 35 | { 36 | link: 'phone', 37 | text: 'Phone List' 38 | }, 39 | { 40 | link: false, 41 | text: 'Add Phone' 42 | } 43 | ] 44 | } 45 | }; 46 | page.assertCorrectLayout(config); 47 | }); 48 | }); 49 | 50 | describe('Phone Add section:', () => { 51 | it('should have correct initial state', () => { 52 | expect(page.ele.title.getText()).toEqual('Add a new phone'); 53 | expect(page.ele.form.ele.cancelBtn.isDisplayed()).toBe(false); 54 | expect(page.ele.form.ele.saveBtn.isEnabled()).toBe(false); 55 | page.ele.form.assertEditPhoneDetail(page.data.phone); 56 | }); 57 | 58 | it('should add phone correctly via form', () => { 59 | const now = new Date(); 60 | const newPhone = { 61 | Model: 'iPhone 17', 62 | OS: 'iOS', 63 | Price: '9999', 64 | 'Screen Size': '9.9', 65 | Manufacturer: 'AppleApple', 66 | 'Release Date': 'November 20, 2015', 67 | // always use today's date for easy testing 68 | date: `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}` 69 | }; 70 | page.ele.form.assertEditingForm(newPhone, true); 71 | // back to phone main page 72 | browser._.expectUrlToMatch(page.phonePage.url); 73 | // check last item is the new added item 74 | const phoneList = page.phonePage.ele.phoneItem; 75 | expect(phoneList.view.count()).toEqual(6); 76 | const lastItem = phoneList.view.get(5); 77 | const expectedItem = [newPhone.Model, newPhone.OS, newPhone.Price]; 78 | browser._.isMobile().then((isSmall) => { 79 | lastItem.$$(phoneList.cell).each((td, index) => { 80 | if (index === 3) { 81 | // ignore the last cell 82 | return; 83 | } 84 | if (isSmall && (index === 1 || index === 2)) { 85 | expect(td.isDisplayed()).toBe(false); 86 | } else { 87 | expect(td.getText()).toEqual(expectedItem[index]); 88 | } 89 | }); 90 | }); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /source/test/e2e/specs/phone-detail.spec.js: -------------------------------------------------------------------------------- 1 | import PhoneDetailPage from './page-objects/phone-detail.page'; 2 | 3 | describe('Phone Detail Page:', () => { 4 | let page; 5 | beforeEach(() => { 6 | page = new PhoneDetailPage(); 7 | page.load(); 8 | }); 9 | 10 | describe('Layout:', () => { 11 | it('should have correct layout', () => { 12 | const config = { 13 | url: 'phone/1', 14 | title: 'Phone', 15 | klass: 'phone', 16 | header: 'login', 17 | sidebar: { 18 | items: [ 19 | { 20 | link: 'dashboard', 21 | active: false, 22 | icon: 'mdi-action-dashboard', 23 | text: 'DASHBOARD' 24 | }, 25 | { 26 | link: 'phone', 27 | active: true, 28 | icon: 'mdi-hardware-phone-android', 29 | text: 'PHONES' 30 | } 31 | ] 32 | }, 33 | breadcrumb: { 34 | items: [ 35 | { 36 | link: 'phone', 37 | text: 'Phone List' 38 | }, 39 | { 40 | link: false, 41 | text: 'Phone Detail' 42 | } 43 | ] 44 | } 45 | }; 46 | page.assertCorrectLayout(config); 47 | }); 48 | }); 49 | 50 | describe('Phone Detail section:', () => { 51 | it('should show correct phone detail', () => { 52 | expect(page.ele.title.getText()).toEqual(page.data.phone.Model); 53 | expect(page.ele.editBtn.$('i')).toHaveClass('mdi-image-edit'); 54 | page.ele.form.assertPhoneDetail(page.data.phone); 55 | }); 56 | }); 57 | 58 | describe('Phone Edit section:', () => { 59 | let formField; 60 | beforeEach(() => { 61 | formField = page.ele.form.ele.field; 62 | }); 63 | 64 | it('should show phone edit view correctly', () => { 65 | expect(formField.view.get(0).isDisplayed()).toBe(false); 66 | expect(page.ele.form.ele.cancelBtn.isDisplayed()).toBe(false); 67 | expect(page.ele.form.ele.saveBtn.isDisplayed()).toBe(false); 68 | // click edit 69 | page.ele.editBtn.click(); 70 | expect(formField.view.get(0).isDisplayed()).toBe(true); 71 | expect(page.ele.form.ele.cancelBtn.isDisplayed()).toBe(true); 72 | expect(page.ele.form.ele.saveBtn.isDisplayed()).toBe(true); 73 | expect(page.ele.form.ele.saveBtn.isEnabled()).toBe(false); 74 | // check label/value 75 | page.ele.form.assertEditPhoneDetail(page.data.phone); 76 | }); 77 | 78 | it('should cancel editing after clicking cancel button', () => { 79 | // click edit 80 | page.ele.editBtn.click(); 81 | // edit model 82 | const modelInput = formField.view.get(0).$(formField.input); 83 | modelInput.clear(); 84 | modelInput.sendKeys('abcdefg'); 85 | // click cancel 86 | page.ele.form.ele.cancelBtn.click(); 87 | // check phone detail not changed 88 | page.ele.form.assertPhoneDetail(page.data.phone); 89 | }); 90 | 91 | it('should allow editing every field in form', () => { 92 | const newPhone = { 93 | Model: 'iPhone 6 123', 94 | OS: 'Android', 95 | Price: '1234', 96 | 'Screen Size': '12.3', 97 | Manufacturer: 'abcd', 98 | 'Release Date': 'October 17, 2014', 99 | date: '2014-10-17' 100 | }; 101 | page.ele.editBtn.click(); 102 | page.ele.form.assertEditingForm(newPhone, false); 103 | // check new phone detail 104 | page.ele.form.assertPhoneDetail(newPhone); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /source/test/unit/helper.js: -------------------------------------------------------------------------------- 1 | import 'angular'; 2 | import 'angular-mocks'; 3 | import 'materialize-css/dist/js/materialize.js'; 4 | 5 | const testsContext = require.context('../../app', true, /\.spec\.js$/); 6 | testsContext.keys().forEach(testsContext); 7 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var ExtractTextPlugin = require("extract-text-webpack-plugin"); 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | var autoprefixer = require('autoprefixer'); 6 | var args = require('yargs').argv; 7 | 8 | // parameters 9 | var isProd = args.prod; 10 | var isMock = args.mock; 11 | 12 | var base = './'; 13 | // use mock api or not 14 | var entryJs = isMock ? 15 | base + 'source/test/e2e/mocks/index.js' : 16 | base + 'source/app/index.js'; 17 | var appName = isMock ? 'appTest' : 'app'; 18 | 19 | var plugins = [ 20 | new webpack.ProvidePlugin({ 21 | $: "jquery", 22 | jQuery: "jquery", 23 | // materialize-css rely on this to support velocity 24 | "window.jQuery": "jquery" 25 | }), 26 | new webpack.DefinePlugin({ 27 | __PROD__: isProd, 28 | __MOCK__: isMock 29 | }), 30 | new webpack.optimize.CommonsChunkPlugin('vendor', isProd ? 'vendor.[hash].js' : 'vendor.js'), 31 | new ExtractTextPlugin(isProd ? '[name].[hash].css' : '[name].css'), 32 | new HtmlWebpackPlugin({ 33 | template: 'jade!./source/app/index.jade', 34 | chunks: ['app', 'vendor'], 35 | favicon: 'favicon.ico', 36 | appName: appName 37 | }), 38 | new CopyWebpackPlugin([ 39 | { from: 'node_modules/babel-core/browser-polyfill.min.js', to: 'polyfill.js'} 40 | ]) 41 | ]; 42 | 43 | if (isProd) { 44 | plugins.push( 45 | new webpack.NoErrorsPlugin(), 46 | new webpack.optimize.DedupePlugin(), 47 | new webpack.optimize.UglifyJsPlugin({ 48 | compress: { 49 | warnings: false 50 | }, 51 | mangle: false 52 | }), 53 | new webpack.optimize.OccurenceOrderPlugin() 54 | ); 55 | } 56 | 57 | module.exports = { 58 | entry: { 59 | app: [ 60 | entryJs 61 | ], 62 | vendor: [ 63 | // 3rd dependencies 64 | 'materialize-css/bin/materialize.css', 65 | 'materialize-css/bin/materialize.js', 66 | 'animate.css/animate.css', 67 | // angular 68 | 'angular', 69 | 'angular-ui-router', 70 | 'angular-animate', 71 | 'angular-messages', 72 | 'angular-mocks', 73 | 'angular-loading-bar', 74 | 'oclazyload' 75 | ] 76 | }, 77 | output: { 78 | path: base + 'build', 79 | filename: isProd ? '[name].[hash].js' : '[name].js', 80 | chunkFilename: isProd ? '[name].[hash].chunk.js' : '[name].chunk.js' 81 | }, 82 | module: { 83 | preLoaders: [ 84 | { 85 | test: /\.js$/, 86 | loader: "eslint", 87 | exclude: /node_modules/ 88 | } 89 | ], 90 | loaders: [ 91 | { 92 | test: /\.js$/, 93 | loader: 'babel', 94 | exclude: /node_modules/ 95 | }, 96 | { 97 | test: /\.html$/, 98 | loader: 'html' 99 | }, 100 | { 101 | test: /\.jade$/, 102 | loader: 'jade', 103 | exclude: /index\.jade/ 104 | }, 105 | { 106 | test: /\.styl$/, 107 | loader: ExtractTextPlugin.extract('style', 'css?sourceMap!postcss!stylus') 108 | }, 109 | { 110 | test: /\.css$/, 111 | loader: ExtractTextPlugin.extract('style', 'css?sourceMap') 112 | }, 113 | { 114 | test: /\.(woff|woff2|ttf|eot|svg)(\?]?.*)?$/, 115 | loader : 'file?name=assets/fonts/[name].[ext]?[hash]' 116 | }, 117 | { 118 | test: /\.(png|jpg)$/, 119 | loader: 'url?limit=8192&name=assets/images/[name].[hash].[ext]' 120 | } 121 | ] 122 | }, 123 | plugins: plugins, 124 | debug: !isProd, 125 | devtool: isProd ? 'source-map' : 'eval-source-map', 126 | devServer: { 127 | contentBase: base + 'build', 128 | historyApiFallback: true, 129 | stats: { 130 | modules: false, 131 | cached: false, 132 | colors: true, 133 | chunk: false 134 | }, 135 | host: '0.0.0.0', 136 | port: 8080 137 | }, 138 | postcss: function () { 139 | return [autoprefixer]; 140 | } 141 | }; 142 | --------------------------------------------------------------------------------