├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── build ├── build.js ├── dev-client.js ├── dev-server.js ├── utils.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config ├── dev.env.js ├── index.js ├── prod.env.js └── test.env.js ├── faviconDescription.json ├── index.html ├── package.json ├── src ├── App.vue ├── api │ └── index.js ├── assets │ ├── github.svg │ ├── logo.png │ ├── logo.svg │ ├── logo_512x512.png │ ├── logo_512x512_rounded.png │ ├── logo_64x64.png │ └── logo_64x64_black.png ├── components │ ├── ChangeColor.vue │ ├── Day.vue │ ├── Footer.vue │ ├── Header.vue │ ├── Info.vue │ ├── InfoRow.vue │ ├── Lesson.vue │ ├── Navigator.vue │ ├── Search.vue │ └── Timetable.vue ├── constants │ └── index.js ├── main.js ├── styles │ ├── base.scss │ └── main.scss └── vuex │ ├── actions.js │ ├── getters.js │ ├── mutation-types.js │ ├── plugin.js │ ├── store.js │ └── substores │ ├── search.js │ └── timetable.js ├── static └── .gitkeep ├── test ├── e2e │ ├── custom-assertions │ │ └── elementCount.js │ ├── nightwatch.conf.js │ ├── runner.js │ └── specs │ │ └── test.js └── unit │ ├── .eslintrc │ ├── index.js │ ├── karma.conf.js │ └── specs │ └── Hello.spec.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": ["transform-runtime"], 4 | "comments": false 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | extends: 'airbnb-base', 8 | // required to lint *.vue files 9 | plugins: [ 10 | 'html' 11 | ], 12 | env: { 13 | browser: true, 14 | node: true, 15 | }, 16 | // adapt most from airbnb, with a few changes 17 | 'rules': { 18 | 'import/no-unresolved': 0, 19 | // TODO: enable when they can recognise .vue extensions 20 | 'import/extensions': 0, 21 | // allow debugger during development 22 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 23 | // allow console during development 24 | 'no-console': process.env.NODE_ENV === 'production' ? 2 : 0, 25 | // allow functions to be hoisted 26 | "no-use-before-define": [2, "nofunc"], 27 | // shadowing and reassign is allowed or else vuex won't work 28 | "no-shadow": 0, 29 | "no-param-reassign": 0, 30 | "no-plusplus": 0, 31 | // not a concern 32 | "linebreak-style": 0, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log 4 | selenium-debug.log 5 | test/unit/coverage 6 | test/e2e/reports 7 | dist 8 | .netlify 9 | tatus 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 li-kai 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 | # Modify.sg 2 | 3 | Modify is a timetable builder for Singapore Universities. 4 | 5 | ### Features 6 | - Responsive: works on all devices 7 | - Colour palette: pick your favourites! 8 | - At a glance information 9 | 10 | ### Tech 11 | Modify uses the following to work: 12 | ##### Front-end 13 | * [Vue.js] - javascript framework 14 | * [Localforage] - client side storage 15 | * [Webpack] - build system and module bundler 16 | 17 | ##### Back-end 18 | * See [Modify-api] 19 | 20 | ### Installation 21 | 22 | Download and install [Node.js](https://docs.npmjs.com/getting-started/installing-node) v4+ to run, then run the following in the terminal: 23 | 24 | ```sh 25 | git clone https://github.com/li-kai/modify.git 26 | cd modify 27 | # install dependencies 28 | npm install 29 | # serve with hot reload at localhost:8080 30 | npm run dev 31 | ``` 32 | Most of the stuff is written in Vue. It's super simple and easy to learn. So learn some [Vue.js](https://laracasts.com/series/learning-vue-step-by-step) to get started. 33 | 34 | For production environments... 35 | 36 | ```sh 37 | npm run build 38 | ``` 39 | 40 | ### Development 41 | 42 | Want to contribute? Great! 43 | 44 | Modify uses Webpack for fast developing thanks to [Vue-template](https://github.com/vuejs-templates/webpack) 45 | 46 | Make a change in your file and instantanously see your updates! 47 | 48 | Run the following commands to run tests: 49 | ```sh 50 | # run unit tests 51 | npm run unit 52 | 53 | # run e2e tests 54 | npm run e2e 55 | 56 | # run all tests 57 | npm test 58 | ``` 59 | 60 | ### Todos 61 | 62 | - Write Tests 63 | - Sticky headers and fab 64 | - Clearer styling for lessons with only one choice 65 | - User onboarding 66 | - Home page 67 | - Accounts 68 | 69 | License 70 | ---- 71 | 72 | [MIT](http://opensource.org/licenses/MIT) 73 | 74 | Copyright (c) 2016- Li Kai 75 | 76 | [//]: # (These are reference links used in the body of this note and get stripped out when the markdown processor does its job. There is no need to format nicely because it shouldn't be seen. Thanks SO - http://stackoverflow.com/questions/4823468/store-comments-in-markdown-syntax) 77 | 78 | [Vue.js]: 79 | [Localforage]: 80 | [Webpack]: 81 | [node.js]: 82 | [Modify-api]: 83 | -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | // https://github.com/shelljs/shelljs 2 | require('shelljs/global') 3 | env.NODE_ENV = 'production' 4 | 5 | var path = require('path') 6 | var config = require('../config') 7 | var ora = require('ora') 8 | var webpack = require('webpack') 9 | var webpackConfig = require('./webpack.prod.conf') 10 | 11 | console.log( 12 | ' Tip:\n' + 13 | ' Built files are meant to be served over an HTTP server.\n' + 14 | ' Opening index.html over file:// won\'t work.\n' 15 | ) 16 | 17 | var spinner = ora('building for production...') 18 | spinner.start() 19 | 20 | var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) 21 | rm('-rf', assetsPath) 22 | mkdir('-p', assetsPath) 23 | cp('-R', 'static/', assetsPath) 24 | 25 | webpack(webpackConfig, function (err, stats) { 26 | spinner.stop() 27 | if (err) throw err 28 | process.stdout.write(stats.toString({ 29 | colors: true, 30 | modules: false, 31 | children: false, 32 | chunks: false, 33 | chunkModules: false 34 | }) + '\n') 35 | 36 | exec('real-favicon generate faviconDescription.json faviconData.json ' + config.build.assetsRoot) 37 | /* If there are any changes to favicons, uncomment the bottom 2 lines to update the index.html 38 | then copy over from the built version to the index, as the bottom undoes the html minification */ 39 | // exec('real-favicon inject faviconData.json ' + config.build.assetsRoot + ' *.html') 40 | // exec('real-favicon check-for-update --fail-on-update faviconData.json') 41 | rm(path.resolve('faviconData.json')); 42 | }) -------------------------------------------------------------------------------- /build/dev-client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | require('eventsource-polyfill') 3 | var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 4 | 5 | hotClient.subscribe(function (event) { 6 | if (event.action === 'reload') { 7 | window.location.reload() 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /build/dev-server.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var express = require('express') 3 | var webpack = require('webpack') 4 | var config = require('../config') 5 | var proxyMiddleware = require('http-proxy-middleware') 6 | var webpackConfig = process.env.NODE_ENV === 'testing' 7 | ? require('./webpack.prod.conf') 8 | : require('./webpack.dev.conf') 9 | 10 | // default port where dev server listens for incoming traffic 11 | var port = process.env.PORT || config.dev.port 12 | // Define HTTP proxies to your custom API backend 13 | // https://github.com/chimurai/http-proxy-middleware 14 | var proxyTable = config.dev.proxyTable 15 | 16 | var app = express() 17 | var compiler = webpack(webpackConfig) 18 | 19 | var devMiddleware = require('webpack-dev-middleware')(compiler, { 20 | publicPath: webpackConfig.output.publicPath, 21 | stats: { 22 | colors: true, 23 | chunks: false 24 | } 25 | }) 26 | 27 | var hotMiddleware = require('webpack-hot-middleware')(compiler) 28 | // force page reload when html-webpack-plugin template changes 29 | compiler.plugin('compilation', function (compilation) { 30 | compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { 31 | hotMiddleware.publish({ action: 'reload' }) 32 | cb() 33 | }) 34 | }) 35 | 36 | // proxy api requests 37 | Object.keys(proxyTable).forEach(function (context) { 38 | var options = proxyTable[context] 39 | if (typeof options === 'string') { 40 | options = { target: options } 41 | } 42 | app.use(proxyMiddleware(context, options)) 43 | }) 44 | 45 | // handle fallback for HTML5 history API 46 | app.use(require('connect-history-api-fallback')()) 47 | 48 | // serve webpack bundle output 49 | app.use(devMiddleware) 50 | 51 | // enable hot-reload and state-preserving 52 | // compilation error display 53 | app.use(hotMiddleware) 54 | 55 | // serve pure static assets 56 | var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) 57 | app.use(staticPath, express.static('./static')) 58 | 59 | module.exports = app.listen(port, function (err) { 60 | if (err) { 61 | console.log(err) 62 | return 63 | } 64 | console.log('Listening at http://localhost:' + port + '\n') 65 | }) 66 | -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 4 | 5 | exports.assetsPath = function (_path) { 6 | return path.posix.join(config.build.assetsSubDirectory, _path) 7 | } 8 | 9 | exports.cssLoaders = function (options) { 10 | options = options || {} 11 | // generate loader string to be used with extract text plugin 12 | function generateLoaders (loaders) { 13 | var sourceLoader = loaders.map(function (loader) { 14 | var extraParamChar 15 | if (/\?/.test(loader)) { 16 | loader = loader.replace(/\?/, '-loader?') 17 | extraParamChar = '&' 18 | } else { 19 | loader = loader + '-loader' 20 | extraParamChar = '?' 21 | } 22 | return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '') 23 | }).join('!') 24 | 25 | if (options.extract) { 26 | return ExtractTextPlugin.extract('vue-style-loader', sourceLoader) 27 | } else { 28 | return ['vue-style-loader', sourceLoader].join('!') 29 | } 30 | } 31 | 32 | // http://vuejs.github.io/vue-loader/configurations/extract-css.html 33 | return { 34 | css: generateLoaders(['css']), 35 | postcss: generateLoaders(['css']), 36 | less: generateLoaders(['css', 'less']), 37 | sass: generateLoaders(['css', 'sass?indentedSyntax']), 38 | scss: generateLoaders(['css', 'sass']), 39 | stylus: generateLoaders(['css', 'stylus']), 40 | styl: generateLoaders(['css', 'stylus']) 41 | } 42 | } 43 | 44 | // Generate loaders for standalone style files (outside of .vue) 45 | exports.styleLoaders = function (options) { 46 | var output = [] 47 | var loaders = exports.cssLoaders(options) 48 | for (var extension in loaders) { 49 | var loader = loaders[extension] 50 | output.push({ 51 | test: new RegExp('\\.' + extension + '$'), 52 | loader: loader 53 | }) 54 | } 55 | return output 56 | } 57 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var utils = require('./utils') 4 | var projectRoot = path.resolve(__dirname, '../') 5 | 6 | module.exports = { 7 | entry: { 8 | app: './src/main.js' 9 | }, 10 | output: { 11 | path: config.build.assetsRoot, 12 | publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, 13 | filename: '[name].js' 14 | }, 15 | resolve: { 16 | extensions: ['', '.js', '.vue'], 17 | fallback: [path.join(__dirname, '../node_modules')], 18 | alias: { 19 | 'src': path.resolve(__dirname, '../src'), 20 | 'assets': path.resolve(__dirname, '../src/assets'), 21 | 'components': path.resolve(__dirname, '../src/components') 22 | } 23 | }, 24 | resolveLoader: { 25 | fallback: [path.join(__dirname, '../node_modules')] 26 | }, 27 | module: { 28 | noParse: /[\/\\]node_modules[\/\\]localforage[\/\\]dist[\/\\]localforage\.js$/, 29 | preLoaders: [ 30 | { 31 | test: /\.vue$/, 32 | loader: 'eslint', 33 | include: projectRoot, 34 | exclude: /node_modules/ 35 | }, 36 | { 37 | test: /\.js$/, 38 | loader: 'eslint', 39 | include: projectRoot, 40 | exclude: /node_modules/ 41 | } 42 | ], 43 | loaders: [ 44 | { 45 | test: /\.vue$/, 46 | loader: 'vue' 47 | }, 48 | { 49 | test: /\.js$/, 50 | loader: 'babel', 51 | include: projectRoot, 52 | exclude: /node_modules/ 53 | }, 54 | { 55 | test: /\.json$/, 56 | loader: 'json' 57 | }, 58 | { 59 | test: /\.html$/, 60 | loader: 'vue-html' 61 | }, 62 | { 63 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 64 | loader: 'url', 65 | query: { 66 | limit: 10000, 67 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 68 | } 69 | }, 70 | { 71 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 72 | loader: 'url', 73 | query: { 74 | limit: 10000, 75 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 76 | } 77 | } 78 | ] 79 | }, 80 | eslint: { 81 | formatter: require('eslint-friendly-formatter') 82 | }, 83 | vue: { 84 | autoprefixer: { 85 | browsers: ['not ie <= 9'] 86 | }, 87 | loaders: utils.cssLoaders() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | var config = require('../config') 2 | var webpack = require('webpack') 3 | var merge = require('webpack-merge') 4 | var utils = require('./utils') 5 | var baseWebpackConfig = require('./webpack.base.conf') 6 | var HtmlWebpackPlugin = require('html-webpack-plugin') 7 | 8 | // add hot-reload related code to entry chunks 9 | Object.keys(baseWebpackConfig.entry).forEach(function (name) { 10 | baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) 11 | }) 12 | 13 | module.exports = merge(baseWebpackConfig, { 14 | module: { 15 | loaders: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) 16 | }, 17 | // eval-source-map is faster for development 18 | devtool: '#eval-source-map', 19 | plugins: [ 20 | new webpack.DefinePlugin({ 21 | 'process.env': config.dev.env 22 | }), 23 | // https://github.com/glenjamin/webpack-hot-middleware#installation--usage 24 | new webpack.optimize.OccurenceOrderPlugin(), 25 | new webpack.HotModuleReplacementPlugin(), 26 | new webpack.NoErrorsPlugin(), 27 | // https://github.com/ampedandwired/html-webpack-plugin 28 | new HtmlWebpackPlugin({ 29 | filename: 'index.html', 30 | template: 'index.html', 31 | inject: true 32 | }) 33 | ] 34 | }) 35 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var utils = require('./utils') 4 | var webpack = require('webpack') 5 | var merge = require('webpack-merge') 6 | var baseWebpackConfig = require('./webpack.base.conf') 7 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 8 | var HtmlWebpackPlugin = require('html-webpack-plugin') 9 | var env = process.env.NODE_ENV === 'testing' 10 | ? require('../config/test.env') 11 | : config.build.env 12 | 13 | var webpackConfig = merge(baseWebpackConfig, { 14 | module: { 15 | loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true }) 16 | }, 17 | devtool: config.build.productionSourceMap ? '#source-map' : false, 18 | output: { 19 | path: config.build.assetsRoot, 20 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 21 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 22 | }, 23 | vue: { 24 | loaders: utils.cssLoaders({ 25 | sourceMap: config.build.productionSourceMap, 26 | extract: true 27 | }) 28 | }, 29 | plugins: [ 30 | // http://vuejs.github.io/vue-loader/workflow/production.html 31 | new webpack.DefinePlugin({ 32 | 'process.env': env 33 | }), 34 | new webpack.optimize.UglifyJsPlugin({ 35 | compress: { 36 | warnings: false 37 | } 38 | }), 39 | new webpack.optimize.OccurenceOrderPlugin(), 40 | // extract css into its own file 41 | new ExtractTextPlugin(utils.assetsPath('css/[name].[contenthash].css')), 42 | // generate dist index.html with correct asset hash for caching. 43 | // you can customize output by editing /index.html 44 | // see https://github.com/ampedandwired/html-webpack-plugin 45 | new HtmlWebpackPlugin({ 46 | filename: process.env.NODE_ENV === 'testing' 47 | ? 'index.html' 48 | : config.build.index, 49 | template: 'index.html', 50 | inject: true, 51 | minify: { 52 | removeComments: true, 53 | collapseWhitespace: true, 54 | removeAttributeQuotes: true 55 | // more options: 56 | // https://github.com/kangax/html-minifier#options-quick-reference 57 | }, 58 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 59 | chunksSortMode: 'dependency' 60 | }), 61 | // split vendor js into its own file 62 | new webpack.optimize.CommonsChunkPlugin({ 63 | name: 'vendor', 64 | minChunks: function (module, count) { 65 | // any required modules inside node_modules are extracted to vendor 66 | return ( 67 | module.resource && 68 | /\.js$/.test(module.resource) && 69 | module.resource.indexOf( 70 | path.join(__dirname, '../node_modules') 71 | ) === 0 72 | ) 73 | } 74 | }), 75 | // extract webpack runtime and module manifest to its own file in order to 76 | // prevent vendor hash from being updated whenever app bundle is updated 77 | new webpack.optimize.CommonsChunkPlugin({ 78 | name: 'manifest', 79 | chunks: ['vendor'] 80 | }) 81 | ] 82 | }) 83 | 84 | if (config.build.productionGzip) { 85 | var CompressionWebpackPlugin = require('compression-webpack-plugin') 86 | 87 | webpackConfig.plugins.push( 88 | new CompressionWebpackPlugin({ 89 | asset: '[path].gz[query]', 90 | algorithm: 'gzip', 91 | test: new RegExp( 92 | '\\.(' + 93 | config.build.productionGzipExtensions.join('|') + 94 | ')$' 95 | ), 96 | threshold: 1400, 97 | minRatio: 0.8 98 | }) 99 | ) 100 | } 101 | 102 | module.exports = webpackConfig 103 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var prodEnv = require('./prod.env') 3 | 4 | module.exports = merge(prodEnv, { 5 | NODE_ENV: '"development"' 6 | }) 7 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | var path = require('path') 3 | 4 | module.exports = { 5 | build: { 6 | env: require('./prod.env'), 7 | index: path.resolve(__dirname, '../dist/index.html'), 8 | assetsRoot: path.resolve(__dirname, '../dist'), 9 | assetsSubDirectory: 'static', 10 | assetsPublicPath: '/', 11 | productionSourceMap: true, 12 | productionGzip: true, 13 | productionGzipExtensions: ['js', 'css'] 14 | }, 15 | dev: { 16 | env: require('./dev.env'), 17 | port: 8080, 18 | assetsSubDirectory: 'static', 19 | assetsPublicPath: '/', 20 | proxyTable: {}, 21 | // CSS Sourcemaps off by default because relative paths are "buggy" 22 | // with this option, according to the CSS-Loader README 23 | // (https://github.com/webpack/css-loader#sourcemaps) 24 | // In our experience, they generally work as expected, 25 | // just be aware of this issue when enabling this option. 26 | cssSourceMap: false, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"' 3 | } 4 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var devEnv = require('./dev.env') 3 | 4 | module.exports = merge(devEnv, { 5 | NODE_ENV: '"testing"' 6 | }) 7 | -------------------------------------------------------------------------------- /faviconDescription.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "masterPicture": "./src/assets/logo_512x512.png", 4 | "iconsPath": "/", 5 | "design": { 6 | "ios": { 7 | "pictureAspect": "backgroundAndMargin", 8 | "backgroundColor": "#ffffff", 9 | "margin": "18%", 10 | "assets": { 11 | "ios6AndPriorIcons": false, 12 | "ios7AndLaterIcons": false, 13 | "precomposedIcons": false, 14 | "declareOnlyDefaultIcon": true 15 | } 16 | }, 17 | "desktopBrowser": {}, 18 | "windows": { 19 | "pictureAspect": "noChange", 20 | "backgroundColor": "#2b5797", 21 | "onConflict": "override", 22 | "assets": { 23 | "windows80Ie10Tile": false, 24 | "windows10Ie11EdgeTiles": { 25 | "small": false, 26 | "medium": true, 27 | "big": true, 28 | "rectangle": false 29 | } 30 | } 31 | }, 32 | "androidChrome": { 33 | "masterPicture": "./src/assets/logo_512x512_rounded.png", 34 | "pictureAspect": "shadow", 35 | "themeColor": "#ffffff", 36 | "manifest": { 37 | "name": "Modify", 38 | "startUrl": "https://www.modify.sg", 39 | "display": "standalone", 40 | "orientation": "notSet", 41 | "onConflict": "override", 42 | "declared": true 43 | }, 44 | "assets": { 45 | "legacyIcon": false, 46 | "lowResolutionIcons": false 47 | } 48 | }, 49 | "safariPinnedTab": { 50 | "pictureAspect": "silhouette", 51 | "themeColor": "#e84664" 52 | } 53 | }, 54 | "settings": { 55 | "compression": 1, 56 | "scalingAlgorithm": "NearestNeighbor", 57 | "errorOnImageTooSmall": false 58 | } 59 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Modify 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Modify", 3 | "version": "1.0.0", 4 | "description": "Timetable planner for Singapore universities", 5 | "author": "li-kai ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "node build/dev-server.js", 9 | "build": "node build/build.js", 10 | "unit": "karma start test/unit/karma.conf.js --single-run", 11 | "e2e": "node test/e2e/runner.js", 12 | "test": "npm run unit && npm run e2e", 13 | "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs" 14 | }, 15 | "dependencies": { 16 | "dom-to-image": "^2.5.2", 17 | "localforage": "^1.4.2", 18 | "vue": "^2.1.3", 19 | "vue-resource": "^0.9.3", 20 | "vue-router": "^2.0.0", 21 | "vuex": "^1.0.0-rc" 22 | }, 23 | "devDependencies": { 24 | "babel-core": "^6.0.0", 25 | "babel-eslint": "^7.0.0", 26 | "babel-loader": "^6.0.0", 27 | "babel-plugin-transform-runtime": "^6.0.0", 28 | "babel-preset-es2015": "^6.0.0", 29 | "babel-preset-stage-2": "^6.0.0", 30 | "babel-register": "^6.0.0", 31 | "babel-runtime": "^6.0.0", 32 | "chai": "^3.5.0", 33 | "chromedriver": "^2.21.2", 34 | "cli-real-favicon": "0.0.6", 35 | "compression-webpack-plugin": "^0.3.1", 36 | "connect-history-api-fallback": "^1.1.0", 37 | "cross-spawn": "^2.1.5", 38 | "css-loader": "^0.25.0", 39 | "eslint": "^3.6.1", 40 | "eslint-config-airbnb-base": "^8.0.0", 41 | "eslint-friendly-formatter": "^2.0.5", 42 | "eslint-loader": "^1.3.0", 43 | "eslint-plugin-html": "^1.3.0", 44 | "eslint-plugin-import": "^1.8.1", 45 | "eventsource-polyfill": "^0.9.6", 46 | "express": "^4.13.3", 47 | "extract-text-webpack-plugin": "^1.0.1", 48 | "file-loader": "^0.9.0", 49 | "function-bind": "^1.0.2", 50 | "html-webpack-plugin": "^2.8.1", 51 | "http-proxy-middleware": "^0.17.1", 52 | "inject-loader": " ^2.0.1", 53 | "isparta-loader": "^2.0.0", 54 | "json-loader": "^0.5.4", 55 | "karma": "^1.3.0", 56 | "karma-coverage": "^1.1.1", 57 | "karma-mocha": "^1.2.0", 58 | "karma-phantomjs-launcher": "^1.0.0", 59 | "karma-sinon-chai": "^1.2.0", 60 | "karma-sourcemap-loader": "^0.3.7", 61 | "karma-spec-reporter": "0.0.26", 62 | "karma-webpack": "^1.7.0", 63 | "lolex": "^1.4.0", 64 | "mocha": "^3.1.0", 65 | "nightwatch": "^0.9.8", 66 | "node-sass": "^3.8.0", 67 | "ora": "^0.3.0", 68 | "phantomjs-prebuilt": "^2.1.3", 69 | "sass-loader": "^4.0.0", 70 | "selenium-server": "2.53.1", 71 | "shelljs": "^0.7.4", 72 | "sinon": "^1.17.3", 73 | "sinon-chai": "^2.8.0", 74 | "url-loader": "^0.5.7", 75 | "vue-hot-reload-api": "^1.2.0", 76 | "vue-html-loader": "^1.0.0", 77 | "vue-loader": "^9.0.0", 78 | "vue-style-loader": "^1.0.0", 79 | "webpack": "^1.13.2", 80 | "webpack-dev-middleware": "^1.4.0", 81 | "webpack-hot-middleware": "^2.6.0", 82 | "webpack-merge": "^0.14.1" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 74 | 75 | 103 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueResource from 'vue-resource'; 3 | import localforage from 'localforage'; 4 | import { 5 | USER_MODULES_KEY, 6 | MODULES_LIST_KEY, 7 | USER_SETTINGS_KEY, 8 | userOnboardModule, 9 | } from '../constants'; 10 | 11 | Vue.use(VueResource); 12 | // Vue.http.options.crossOrigin = true; 13 | // Vue.http.options.xhr = { withCredentials: true }; 14 | const API_ROOT = 'https://api.modify.sg/'; 15 | const ModulesListResource = Vue.resource(`${API_ROOT}modulesList/{school}/{year}/{sem}`); 16 | const ModuleResource = Vue.resource(`${API_ROOT}modules/{school}/{year}/{sem}/{moduleCode}`); 17 | 18 | function getFromForage(key, apiCall) { 19 | return localforage.getItem(key).then((value) => { 20 | if (value) { 21 | const differenceInSeconds = (new Date() - value.date) / 1000; 22 | // cache for one day 23 | if (Math.abs(differenceInSeconds) < 86400) { 24 | return Promise.resolve(value.data); 25 | } 26 | } 27 | return apiCall.then((response) => { 28 | const data = response.json(); 29 | const object = { 30 | data, 31 | date: new Date(), 32 | }; 33 | // save to local forage with date 34 | localforage.setItem(key, object); 35 | return data; 36 | }); 37 | }); 38 | } 39 | 40 | /** 41 | * Returns either user modules if they are present, or retrieve onboard module 42 | */ 43 | function getUserModulesOrOnboardModules(combinedKey) { 44 | return localforage.getItem(combinedKey).then((value) => { 45 | if (value) { 46 | return Promise.resolve(value); 47 | } 48 | // check all keys, and check if user has ANY previous usage 49 | return localforage.keys().then((keys) => { 50 | // do some clean up on this step 51 | if (keys.length > 50) { 52 | keys.forEach((key) => { 53 | if (key.indexOf('-') === -1 && key.indexOf('user') === -1) { 54 | localforage.removeItem(key); 55 | } 56 | }); 57 | } 58 | // user has used modify before 59 | if (keys.some(key => key.indexOf(USER_MODULES_KEY) !== -1)) { 60 | return Promise.resolve(null); // but not this particular set 61 | } 62 | return Promise.resolve(userOnboardModule); 63 | }); 64 | }); 65 | } 66 | 67 | export default { 68 | getDefault() { 69 | return localforage.getItem(USER_SETTINGS_KEY); 70 | }, 71 | getModulesList(school, year, sem) { 72 | return getFromForage( 73 | MODULES_LIST_KEY + school + year + sem, 74 | ModulesListResource.get({ school, year, sem }), 75 | ); 76 | }, 77 | getUserModules(school, year, sem) { 78 | return getUserModulesOrOnboardModules(USER_MODULES_KEY + school + year + sem); 79 | }, 80 | getModule(school, year, sem, moduleCode) { 81 | return getFromForage( 82 | moduleCode, 83 | ModuleResource.get({ school, year, sem, moduleCode }) 84 | ); 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /src/assets/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/li-kai/modify/f5de40f63e25e932e912eec344e71892a11c3529/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/logo_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/li-kai/modify/f5de40f63e25e932e912eec344e71892a11c3529/src/assets/logo_512x512.png -------------------------------------------------------------------------------- /src/assets/logo_512x512_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/li-kai/modify/f5de40f63e25e932e912eec344e71892a11c3529/src/assets/logo_512x512_rounded.png -------------------------------------------------------------------------------- /src/assets/logo_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/li-kai/modify/f5de40f63e25e932e912eec344e71892a11c3529/src/assets/logo_64x64.png -------------------------------------------------------------------------------- /src/assets/logo_64x64_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/li-kai/modify/f5de40f63e25e932e912eec344e71892a11c3529/src/assets/logo_64x64_black.png -------------------------------------------------------------------------------- /src/components/ChangeColor.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 51 | 52 | 115 | -------------------------------------------------------------------------------- /src/components/Day.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 128 | 129 | 241 | -------------------------------------------------------------------------------- /src/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 46 | -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 59 | -------------------------------------------------------------------------------- /src/components/Info.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 57 | 58 | 156 | -------------------------------------------------------------------------------- /src/components/InfoRow.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 144 | 145 | 319 | -------------------------------------------------------------------------------- /src/components/Lesson.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 112 | 113 | 203 | -------------------------------------------------------------------------------- /src/components/Navigator.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 88 | 89 | 168 | -------------------------------------------------------------------------------- /src/components/Search.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 225 | 226 | 439 | -------------------------------------------------------------------------------- /src/components/Timetable.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 109 | 110 | 199 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const userOnboardModule = [ 2 | { 3 | availability: null, 4 | code: 'MOD101', 5 | color: '#E84664', 6 | credit: 0, 7 | department: 'Modify', 8 | description: 'Just showing you the ropes, delete me!', 9 | examDuration: null, 10 | examTime: null, 11 | examVenue: null, 12 | preclusion: null, 13 | prerequisite: null, 14 | remarks: null, 15 | timetable: { 16 | Fixed: [ 17 | { 18 | code: 'MOD101', 19 | lessonType: 'I am', 20 | classNo: 'fixed.', 21 | venue: 'I\'m your only', 22 | weekText: 'choice.', 23 | dayText: 'tue', 24 | displayStatus: 'only', 25 | startTime: '1000', 26 | endTime: '1200', 27 | hours: 2, 28 | uid: 'MOD1010lecFixedIcan\'t', 29 | }, 30 | ], 31 | Click: [ 32 | { 33 | code: 'MOD101', 34 | lessonType: 'Click', 35 | classNo: 'on me!', 36 | venue: 'I can be', 37 | weekText: 'swapped.', 38 | dayText: 'mon', 39 | displayStatus: 'selected', 40 | startTime: '0900', 41 | endTime: '1000', 42 | hours: 1, 43 | uid: 'MOD101Clickon me!I can', 44 | }, 45 | { 46 | code: 'MOD101', 47 | lessonType: 'Click', 48 | classNo: 'to select', 49 | venue: 'me. I will be', 50 | weekText: 'swapped.', 51 | dayText: 'mon', 52 | displayStatus: 'hidden', 53 | startTime: '1000', 54 | endTime: '1100', 55 | hours: 1, 56 | uid: 'MOD101Clickto select me.I was', 57 | }, 58 | ], 59 | }, 60 | title: 'Intro to Modify', 61 | }, 62 | ]; 63 | 64 | /* eslint-disable */ 65 | // Hex code for colors 66 | export const colorsList = ['#42A5F5', '#4CAF50', '#EBB72C', 67 | '#f64747', '#FF8300', '#BA68C8', 68 | '#7BC0BF', '#607D8B', '#919191']; 69 | /* eslint-enable */ 70 | 71 | export const USER_MODULES_KEY = 'user-modules'; 72 | export const MODULES_LIST_KEY = 'modify-modules'; 73 | export const USER_SETTINGS_KEY = 'user-settings'; 74 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App'; 3 | 4 | /* eslint-disable no-new */ 5 | new Vue({ 6 | el: 'body', 7 | render: h => h(App), 8 | }); 9 | -------------------------------------------------------------------------------- /src/styles/base.scss: -------------------------------------------------------------------------------- 1 | $footerColor: rgb(66, 67, 72); 2 | 3 | $modulesBackground: #f1f1f1; 4 | $timetableBorder: 0.0625rem solid currentColor; 5 | $errorColor: #ff0033; 6 | $hoverColor: #E0E0E0; 7 | 8 | $bezierSharpCurve: cubic-bezier(0.4, 0.0, 0.6, 1); 9 | $bezierStandardCurve: cubic-bezier(0.4, 0.0, 0.2, 1); 10 | 11 | $standardTransition: all 0.225s $bezierStandardCurve; 12 | 13 | $materialBoxShadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 14 | 15 | $fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 16 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | /* Adapted from normalize.css */ 2 | /*! normalize.css v4.2.0 | MIT License | github.com/necolas/normalize.css */ 3 | 4 | /** 5 | * 1. Change the default font family 6 | * 2. Correct the line height in all browsers. 7 | * 3. Prevent adjustments of font size after orientation changes in IE and iOS. 8 | */ 9 | html { 10 | font-family: $fontFamily; /* 1 */ 11 | font-weight: 400; 12 | font-size: 16px; 13 | line-height: 1.375; /* 2 */ 14 | -ms-text-size-adjust: 100%; /* 3 */ 15 | -webkit-text-size-adjust: 100%; 16 | height: 100%; 17 | } 18 | 19 | /** 20 | * 1. Remove the margin in all browsers 21 | * 2. Make footer always stay at bottom 22 | */ 23 | body { 24 | margin: 0; /* 1 */ 25 | min-height:100%; /* 2 */ 26 | position:relative; 27 | } 28 | 29 | 30 | /* Default styling for Modify. Skip for more normalize.css 31 | ========================================================================== */ 32 | header { 33 | width: 100%; 34 | height: 48px; 35 | } 36 | 37 | ol, 38 | ul { 39 | list-style: none; 40 | padding: 0; 41 | margin: 0; 42 | } 43 | 44 | th { 45 | text-align: left; 46 | font-weight: 600; 47 | } 48 | 49 | td, button { 50 | padding: 0; 51 | margin: 0; 52 | } 53 | 54 | input { 55 | // follow chrome default styling 56 | border: 1px solid #a9a9a9; 57 | } 58 | 59 | .hidden { 60 | display: none; 61 | background: red; 62 | } 63 | 64 | svg { 65 | height: 1.5rem; 66 | width: 1.5rem; 67 | transition: all 0.225s $bezierStandardCurve; 68 | } 69 | 70 | footer { 71 | width:100%; 72 | position:absolute; 73 | // bottom:0; 74 | left:0; 75 | background: $footerColor; 76 | } 77 | 78 | /* HTML5 display definitions 79 | ========================================================================== */ 80 | 81 | /** 82 | * Add the correct display in IE 9-. 83 | * 1. Add the correct display in Edge, IE, and Firefox. 84 | * 2. Add the correct display in IE. 85 | */ 86 | 87 | article, 88 | aside, 89 | details, /* 1 */ 90 | figcaption, 91 | figure, 92 | footer, 93 | header, 94 | main, /* 2 */ 95 | menu, 96 | nav, 97 | section, 98 | summary { /* 1 */ 99 | display: block; 100 | } 101 | 102 | /** 103 | * Add the correct display in IE 9-. 104 | */ 105 | 106 | audio, 107 | canvas, 108 | progress, 109 | video { 110 | display: inline-block; 111 | } 112 | 113 | /** 114 | * Add the correct display in iOS 4-7. 115 | */ 116 | 117 | audio:not([controls]) { 118 | display: none; 119 | height: 0; 120 | } 121 | 122 | /** 123 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 124 | */ 125 | 126 | progress { 127 | vertical-align: baseline; 128 | } 129 | 130 | /** 131 | * Add the correct display in IE 10-. 132 | * 1. Add the correct display in IE. 133 | */ 134 | 135 | template, /* 1 */ 136 | [hidden] { 137 | display: none; 138 | } 139 | 140 | /* Links 141 | ========================================================================== */ 142 | 143 | /** 144 | * 1. Remove the gray background on active links in IE 10. 145 | * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. 146 | */ 147 | 148 | a { 149 | background-color: transparent; /* 1 */ 150 | -webkit-text-decoration-skip: objects; /* 2 */ 151 | } 152 | 153 | /** 154 | * Remove the outline on focused links when they are also active or hovered 155 | * in all browsers (opinionated). 156 | */ 157 | 158 | a:active, 159 | a:hover { 160 | outline-width: 0; 161 | } 162 | 163 | /* Text-level semantics 164 | ========================================================================== */ 165 | 166 | /** 167 | * 1. Remove the bottom border in Firefox 39-. 168 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 169 | */ 170 | 171 | abbr[title] { 172 | border-bottom: none; /* 1 */ 173 | text-decoration: underline; /* 2 */ 174 | text-decoration: underline dotted; /* 2 */ 175 | } 176 | 177 | /** 178 | * 1. Prevent the duplicate application of `bolder` by the next rule in Safari 6. 179 | * 2. Add the correct font weight in Chrome, Edge, and Safari. 180 | */ 181 | 182 | b, 183 | strong { 184 | font-weight: inherit; /* 1 */ 185 | font-weight: bolder; /* 2 */ 186 | } 187 | 188 | /** 189 | * Add the correct font style in Android 4.3-. 190 | */ 191 | 192 | dfn { 193 | font-style: italic; 194 | } 195 | 196 | /** 197 | * Correct the font size and margin on `h1` elements within `section` and 198 | * `article` contexts in Chrome, Firefox, and Safari. 199 | */ 200 | 201 | h1 { 202 | font-size: 2em; 203 | margin: 0.67em 0; 204 | } 205 | 206 | /** 207 | * Add the correct background and color in IE 9-. 208 | */ 209 | 210 | mark { 211 | background-color: #ff0; 212 | color: #000; 213 | } 214 | 215 | /** 216 | * Add the correct font size in all browsers. 217 | */ 218 | 219 | small { 220 | font-size: 80%; 221 | } 222 | 223 | /** 224 | * Prevent `sub` and `sup` elements from affecting the line height in 225 | * all browsers. 226 | */ 227 | 228 | sub, 229 | sup { 230 | font-size: 75%; 231 | line-height: 0; 232 | position: relative; 233 | vertical-align: baseline; 234 | } 235 | 236 | sub { 237 | bottom: -0.25em; 238 | } 239 | 240 | sup { 241 | top: -0.5em; 242 | } 243 | 244 | /* Embedded content 245 | ========================================================================== */ 246 | 247 | /** 248 | * Remove the border on images inside links in IE 10-. 249 | */ 250 | 251 | img { 252 | border-style: none; 253 | } 254 | 255 | /** 256 | * Hide the overflow in IE. 257 | */ 258 | 259 | svg:not(:root) { 260 | overflow: hidden; 261 | } 262 | 263 | /* Grouping content 264 | ========================================================================== */ 265 | 266 | /** 267 | * 1. Correct the inheritance and scaling of font size in all browsers. 268 | * 2. Correct the odd `em` font sizing in all browsers. 269 | */ 270 | 271 | code, 272 | kbd, 273 | pre, 274 | samp { 275 | font-family: monospace, monospace; /* 1 */ 276 | font-size: 1em; /* 2 */ 277 | } 278 | 279 | /** 280 | * 1. Add the correct box sizing in Firefox. 281 | * 2. Show the overflow in Edge and IE. 282 | */ 283 | 284 | hr { 285 | box-sizing: content-box; /* 1 */ 286 | height: 0; /* 1 */ 287 | overflow: visible; /* 2 */ 288 | } 289 | 290 | /* Forms 291 | ========================================================================== */ 292 | 293 | /** 294 | * 1. Change font properties to `inherit` in all browsers (opinionated). 295 | * 2. Remove the margin in Firefox and Safari. 296 | */ 297 | 298 | button, 299 | input, 300 | optgroup, 301 | select, 302 | textarea { 303 | font: inherit; /* 1 */ 304 | margin: 0; /* 2 */ 305 | } 306 | 307 | /** 308 | * Restore the font weight unset by the previous rule. 309 | */ 310 | 311 | optgroup { 312 | font-weight: bold; 313 | } 314 | 315 | /** 316 | * Show the overflow in IE. 317 | * 1. Show the overflow in Edge. 318 | */ 319 | 320 | button, 321 | input { /* 1 */ 322 | overflow: visible; 323 | } 324 | 325 | /** 326 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 327 | * 1. Remove the inheritance of text transform in Firefox. 328 | */ 329 | 330 | button, 331 | select { /* 1 */ 332 | text-transform: none; 333 | } 334 | 335 | /** 336 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` 337 | * controls in Android 4. 338 | * 2. Correct the inability to style clickable types in iOS and Safari. 339 | */ 340 | 341 | button, 342 | html [type="button"], /* 1 */ 343 | [type="reset"], 344 | [type="submit"] { 345 | -webkit-appearance: button; /* 2 */ 346 | } 347 | 348 | /** 349 | * Remove the inner border and padding in Firefox. 350 | */ 351 | 352 | button::-moz-focus-inner, 353 | [type="button"]::-moz-focus-inner, 354 | [type="reset"]::-moz-focus-inner, 355 | [type="submit"]::-moz-focus-inner { 356 | border-style: none; 357 | padding: 0; 358 | } 359 | 360 | /** 361 | * Restore the focus styles unset by the previous rule. 362 | */ 363 | 364 | button:-moz-focusring, 365 | [type="button"]:-moz-focusring, 366 | [type="reset"]:-moz-focusring, 367 | [type="submit"]:-moz-focusring { 368 | outline: 1px dotted ButtonText; 369 | } 370 | 371 | /** 372 | * Change the border, margin, and padding in all browsers (opinionated). 373 | */ 374 | 375 | fieldset { 376 | border: 1px solid #c0c0c0; 377 | margin: 0 2px; 378 | padding: 0.35em 0.625em 0.75em; 379 | } 380 | 381 | /** 382 | * 1. Correct the text wrapping in Edge and IE. 383 | * 2. Correct the color inheritance from `fieldset` elements in IE. 384 | * 3. Remove the padding so developers are not caught out when they zero out 385 | * `fieldset` elements in all browsers. 386 | */ 387 | 388 | legend { 389 | box-sizing: border-box; /* 1 */ 390 | color: inherit; /* 2 */ 391 | display: table; /* 1 */ 392 | max-width: 100%; /* 1 */ 393 | padding: 0; /* 3 */ 394 | white-space: normal; /* 1 */ 395 | } 396 | 397 | /** 398 | * Remove the default vertical scrollbar in IE. 399 | */ 400 | 401 | textarea { 402 | overflow: auto; 403 | } 404 | 405 | /** 406 | * 1. Add the correct box sizing in IE 10-. 407 | * 2. Remove the padding in IE 10-. 408 | */ 409 | 410 | [type="checkbox"], 411 | [type="radio"] { 412 | box-sizing: border-box; /* 1 */ 413 | padding: 0; /* 2 */ 414 | } 415 | 416 | /** 417 | * Correct the cursor style of increment and decrement buttons in Chrome. 418 | */ 419 | 420 | [type="number"]::-webkit-inner-spin-button, 421 | [type="number"]::-webkit-outer-spin-button { 422 | height: auto; 423 | } 424 | 425 | /** 426 | * 1. Correct the odd appearance in Chrome and Safari. 427 | * 2. Correct the outline style in Safari. 428 | */ 429 | 430 | [type="search"] { 431 | -webkit-appearance: textfield; /* 1 */ 432 | outline-offset: -2px; /* 2 */ 433 | } 434 | 435 | /** 436 | * Remove the inner padding and cancel buttons in Chrome and Safari on OS X. 437 | */ 438 | 439 | [type="search"]::-webkit-search-cancel-button, 440 | [type="search"]::-webkit-search-decoration { 441 | -webkit-appearance: none; 442 | } 443 | 444 | /** 445 | * Correct the text style of placeholders in Chrome, Edge, and Safari. 446 | */ 447 | 448 | ::-webkit-input-placeholder { 449 | color: inherit; 450 | opacity: 0.54; 451 | } 452 | 453 | /** 454 | * 1. Correct the inability to style clickable types in iOS and Safari. 455 | * 2. Change font properties to `inherit` in Safari. 456 | */ 457 | 458 | ::-webkit-file-upload-button { 459 | -webkit-appearance: button; /* 1 */ 460 | font: inherit; /* 2 */ 461 | } 462 | -------------------------------------------------------------------------------- /src/vuex/actions.js: -------------------------------------------------------------------------------- 1 | import api from '../api'; 2 | import * as types from './mutation-types'; 3 | 4 | export const setSchool = ({ dispatch }, school) => { 5 | dispatch(types.SET_SCHOOL, school); 6 | }; 7 | 8 | export const setUserTimetable = ({ dispatch }, school, year, sem) => { 9 | // first get the list of all modules 10 | api.getModulesList(school, year, sem).then((response) => { 11 | dispatch(types.RETRIEVE_MODULES_LIST, response); 12 | }) 13 | .catch(() => { 14 | dispatch(types.RETRIEVE_ALL_ERROR); 15 | }); 16 | // then get the previously saved modules, if any 17 | api.getUserModules(school, year, sem).then((response) => { 18 | dispatch(types.ATTACH_USER_MODULES, response); 19 | }) 20 | .catch(() => { 21 | dispatch(types.RETRIEVE_ALL_ERROR); 22 | }); 23 | }; 24 | 25 | /* 26 | Gets the timetable from local forage, by default meaning if the user 27 | changes the school or semester, this is the last saved timetable. 28 | For a new user, this becomes NTU, 2016, sem 1 timetable. 29 | */ 30 | export const setDefaultTimetable = ({ dispatch }) => { 31 | api.getDefault().then((settings) => { 32 | // by default its 'NTU', for now 33 | if (!settings) { 34 | settings = { 35 | school: 'NTU', 36 | year: 2016, 37 | sem: 1, 38 | }; 39 | } 40 | dispatch(types.SET_SCHOOL, settings.school); 41 | 42 | api.getModulesList(settings.school, settings.year, settings.sem).then((modulesList) => { 43 | dispatch(types.RETRIEVE_MODULES_LIST, modulesList); 44 | }); 45 | api.getUserModules(settings.school, settings.year, settings.sem).then((userModules) => { 46 | dispatch(types.ATTACH_USER_MODULES, userModules); 47 | }); 48 | }) 49 | .catch(() => { 50 | dispatch(types.RETRIEVE_ALL_ERROR); 51 | }); 52 | }; 53 | 54 | export const addModule = ({ dispatch }, school, year, sem, moduleCode) => { 55 | api.getModule(school, year, sem, moduleCode).then((response) => { 56 | dispatch(types.ADD_MODULE, response); 57 | }).catch(() => { 58 | dispatch(types.ADD_ERROR); 59 | }); 60 | }; 61 | 62 | function makeAction(type) { 63 | return ({ dispatch }, arg) => dispatch(type, arg); 64 | } 65 | 66 | export const deleteModule = makeAction(types.DELETE_MODULE); 67 | export const toggleSearchStatus = makeAction(types.TOGGLE_SEARCH_STATUS); 68 | 69 | export const changeModuleColor = ({ dispatch }, module, colorHex) => { 70 | dispatch(types.CHANGE_MODULE_COLOR, module, colorHex); 71 | }; 72 | 73 | export const onClickLesson = makeAction(types.ON_CLICK_LESSON); 74 | export const onClickOutside = ({ dispatch }) => { 75 | dispatch(types.ON_CLICK_OUTSIDE); 76 | }; 77 | -------------------------------------------------------------------------------- /src/vuex/getters.js: -------------------------------------------------------------------------------- 1 | /* gets list of all module codes and descriptions only */ 2 | export const getAllModules = state => state.search.listOfModules; 3 | export const getAllModulesResponse = state => state.search.retrieveAllError; 4 | /* checks if user is searching for a module */ 5 | export const getSearchStatus = state => state.search.isSearching; 6 | 7 | /* gets the specific module timetable */ 8 | export const getSchool = state => state.timetable.school; 9 | export const getYear = state => state.timetable.year; 10 | export const getSemester = state => state.timetable.semester; 11 | export const getWeek = state => state.timetable.week; 12 | export const getUserModules = state => state.timetable.userModules; 13 | export const getNumOfModules = state => Object.keys(state.timetable.userModules).length; 14 | export const getModuleResponse = state => state.timetable.retrieveError; 15 | export const getSelectable = state => state.timetable.selectable; 16 | -------------------------------------------------------------------------------- /src/vuex/mutation-types.js: -------------------------------------------------------------------------------- 1 | export const SET_SCHOOL = 'SET_SCHOOL'; 2 | export const RETRIEVE_MODULES_LIST = 'RETRIEVE_MODULES_LIST'; 3 | export const RETRIEVE_ALL_ERROR = 'RETRIEVE_ALL_ERROR'; 4 | export const ATTACH_USER_MODULES = 'ATTACH_USER_MODULES'; 5 | export const ADD_MODULE = 'ADD_MODULE'; 6 | export const ADD_ERROR = 'ADD_ERROR'; 7 | export const DELETE_MODULE = 'DELETE_MODULE'; 8 | export const HIDE_MODULE = 'HIDE_MODULE'; 9 | export const EDIT_MODULE = 'EDIT_MODULE'; 10 | export const CHANGE_MODULE_COLOR = 'CHANGE_MODULE_COLOR'; 11 | export const TOGGLE_SEARCH_STATUS = 'TOGGLE_SEARCH_STATUS'; 12 | export const ON_CLICK_LESSON = 'ON_CLICK_LESSON'; 13 | export const ON_CLICK_OUTSIDE = 'ON_CLICK_OUTSIDE'; 14 | -------------------------------------------------------------------------------- /src/vuex/plugin.js: -------------------------------------------------------------------------------- 1 | import localforage from 'localforage'; 2 | import { 3 | SET_SCHOOL, 4 | ADD_MODULE, 5 | DELETE_MODULE, 6 | CHANGE_MODULE_COLOR, 7 | ON_CLICK_LESSON, 8 | } from './mutation-types'; 9 | 10 | import { USER_MODULES_KEY, USER_SETTINGS_KEY } from '../constants'; 11 | 12 | const localForagePlugin = (store) => { 13 | store.subscribe((mutation, { timetable }) => { 14 | if (mutation.type === ADD_MODULE || 15 | mutation.type === DELETE_MODULE || 16 | mutation.type === CHANGE_MODULE_COLOR || 17 | // only save when user has selected 18 | (mutation.type === ON_CLICK_LESSON && timetable.selectable.length === 0)) { 19 | // store user modules with its uid 20 | localforage.setItem( 21 | USER_MODULES_KEY + timetable.school + timetable.year + timetable.semester, 22 | timetable.userModules, 23 | ); 24 | } 25 | // todo: more 'set' methods for year and sems 26 | // will not save settings if user did not remove the onboard module 27 | if (mutation.type === SET_SCHOOL || mutation.type === ADD_MODULE) { 28 | localforage.setItem( 29 | USER_SETTINGS_KEY, 30 | { 31 | school: timetable.school, 32 | year: timetable.year, 33 | sem: timetable.semester, 34 | }, 35 | ); 36 | } 37 | }); 38 | }; 39 | 40 | export default localForagePlugin; 41 | -------------------------------------------------------------------------------- /src/vuex/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | import timetable from './substores/timetable'; 5 | import search from './substores/search'; 6 | import plugin from './plugin'; 7 | 8 | // Make vue aware of Vuex 9 | Vue.use(Vuex); 10 | 11 | // Combine the initial state and the mutations to create a Vuex store. 12 | // This store can be linked to our app. 13 | export default new Vuex.Store({ 14 | modules: { 15 | timetable, 16 | search, 17 | }, 18 | plugins: [plugin], 19 | strict: process.env.NODE_ENV !== 'production', 20 | }); 21 | -------------------------------------------------------------------------------- /src/vuex/substores/search.js: -------------------------------------------------------------------------------- 1 | import { 2 | RETRIEVE_MODULES_LIST, 3 | RETRIEVE_ALL_ERROR, 4 | ADD_MODULE, 5 | ATTACH_USER_MODULES, 6 | DELETE_MODULE, 7 | TOGGLE_SEARCH_STATUS, 8 | } from '../mutation-types'; 9 | 10 | const state = { 11 | listOfModules: [], 12 | retrieveAllError: false, 13 | isSearching: false, 14 | deletedModuleIndexes: {}, 15 | }; 16 | 17 | const mutations = { 18 | // Retrieve all modules to be searched 19 | [RETRIEVE_MODULES_LIST](state, list) { 20 | state.retrieveAllError = false; 21 | // sort by alphabetical 22 | list = list.sort((a, b) => a.code.localeCompare(b.code)); 23 | // freeze the objects 24 | list.forEach(x => Object.freeze(x)); 25 | state.listOfModules = list; 26 | }, 27 | [RETRIEVE_ALL_ERROR](state) { 28 | state.retrieveAllError = true; 29 | }, 30 | // Remove from searchable list 31 | [ADD_MODULE](state, module) { 32 | for (let i = 0, len = state.listOfModules.length; i < len; i++) { 33 | const mod = state.listOfModules[i]; 34 | if (module.code === mod.code) { 35 | state.listOfModules.splice(i, 1); 36 | state.deletedModuleIndexes[mod.code] = i; 37 | break; 38 | } 39 | } 40 | }, 41 | /* Remove each module from searchable list 42 | * Looks similar to ADD_MODULE but this removes all 43 | * relevant modules in one parse 44 | */ 45 | [ATTACH_USER_MODULES](state, userModules) { 46 | if (userModules) { 47 | let modulesToRemove = userModules.length; 48 | for (let i = 0, len = state.listOfModules.length - modulesToRemove; i < len; i++) { 49 | const mod = state.listOfModules[i]; 50 | if (userModules.some(module => module.code === mod.code)) { 51 | // remove from listOfModules 52 | state.listOfModules.splice(i, 1); 53 | state.deletedModuleIndexes[mod.code] = i; 54 | modulesToRemove--; 55 | len--; 56 | } 57 | // no more modules to remove, early release 58 | if (modulesToRemove <= 0) { 59 | break; 60 | } 61 | } 62 | } 63 | }, 64 | // Add it back to searchable list 65 | [DELETE_MODULE](state, module) { 66 | const deletedModule = Object.freeze({ 67 | code: module.code, 68 | title: module.title, 69 | }); 70 | const index = state.deletedModuleIndexes[module.code]; 71 | // insert back at the position 72 | state.listOfModules.splice(index, 0, deletedModule); 73 | }, 74 | [TOGGLE_SEARCH_STATUS](state) { 75 | state.isSearching = !state.isSearching; 76 | }, 77 | }; 78 | 79 | export default { 80 | state, 81 | mutations, 82 | }; 83 | -------------------------------------------------------------------------------- /src/vuex/substores/timetable.js: -------------------------------------------------------------------------------- 1 | import { 2 | SET_SCHOOL, 3 | ATTACH_USER_MODULES, 4 | ADD_MODULE, 5 | ADD_ERROR, 6 | CHANGE_MODULE_COLOR, 7 | DELETE_MODULE, 8 | ON_CLICK_LESSON, 9 | ON_CLICK_OUTSIDE, 10 | } from '../mutation-types'; 11 | // For onboarding usage 12 | import { colorsList } from '../../constants'; 13 | /* 14 | Timetable will be in charged of handling lessons from modules. 15 | Specifically, timetable will be doing the following: 16 | - Allocating lesson types 17 | - Allocating lessons to a day (mon, tue, etc.) 18 | - Controlling the visibility status (selected, hidden, fade) 19 | */ 20 | // For visibility of lessons 21 | const SELECTED = 'selected'; 22 | const HIDDEN = 'hidden'; 23 | const GHOSTED = 'ghosted'; 24 | const ONLY = 'only'; 25 | const INITIAL = 'initial'; 26 | 27 | const state = { 28 | school: 'NUS', 29 | year: new Date().getFullYear(), 30 | semester: new Date().getMonth() > 7 ? 1 : 2, 31 | week: { 32 | mon: [], 33 | tue: [], 34 | wed: [], 35 | thu: [], 36 | fri: [], 37 | sat: [], 38 | sun: [], 39 | }, 40 | userModules: [], 41 | selectable: [], 42 | selected: {}, 43 | colorCounter: -1, 44 | retrieveError: false, 45 | }; 46 | 47 | // mutations 48 | const mutations = { 49 | [SET_SCHOOL](state, school) { 50 | if (school === 'NUS' || school === 'NTU') { 51 | state.school = school; 52 | } 53 | }, 54 | [ATTACH_USER_MODULES](state, userModules) { 55 | // clear out remnants 56 | resetTimetable(state); 57 | 58 | // restore modules 59 | if (userModules) { 60 | state.userModules = userModules; 61 | for (let i = state.userModules.length - 1; i >= 0; i--) { 62 | allocateLessons(state, state.userModules[i]); 63 | } 64 | } 65 | sortByLengthDescending(state.week); 66 | }, 67 | [ADD_MODULE](state, module) { 68 | state.retrieveError = false; 69 | if (state.userModules.length === 1 && state.userModules[0].code === 'MOD101') { 70 | resetTimetable(state); 71 | } 72 | 73 | // change all snake_case keys to camelCase ones 74 | const camelCaseModule = {}; 75 | Object.keys(module).forEach((key) => { 76 | camelCaseModule[snakeCaseToCamelCase(key)] = module[key]; 77 | }); 78 | 79 | state.userModules.push(camelCaseModule); 80 | if (camelCaseModule.timetable) { 81 | allocateLessons(state, camelCaseModule); 82 | sortByLengthDescending(state.week); 83 | } 84 | 85 | // wrap color if more than 9 modules 86 | state.colorCounter = (state.colorCounter + 1) % colorsList.length; 87 | camelCaseModule.color = colorsList[state.colorCounter]; 88 | }, 89 | [ADD_ERROR](state) { 90 | state.retrieveError = true; 91 | }, 92 | [CHANGE_MODULE_COLOR](state, module, colorInHex) { 93 | module.color = colorInHex; 94 | }, 95 | [DELETE_MODULE](state, module) { 96 | const week = state.week; 97 | const code = module.code; 98 | const arrayOfKeys = Object.keys(week); 99 | for (let i = arrayOfKeys.length - 1; i >= 0; i--) { 100 | const day = arrayOfKeys[i]; 101 | week[day] = week[day].filter(lesson => lesson.code !== code); 102 | } 103 | const index = state.userModules.indexOf(module); 104 | state.userModules.splice(index, 1); 105 | }, 106 | // user starts to pick lesson type 107 | [ON_CLICK_LESSON](state, selectedLesson) { 108 | // user wants to select another lesson 109 | if (state.selectable.length === 0) { 110 | let module; 111 | // get the reference to modules (Array.prototype.find not in IE) 112 | for (let i = 0, len = state.userModules.length; i < len; i++) { 113 | module = state.userModules[i]; 114 | if (module.code === selectedLesson.code) { 115 | break; 116 | } 117 | } 118 | 119 | // make selectable the list of lessons, make them ghosted 120 | state.selectable = module.timetable[selectedLesson.lessonType]; 121 | for (let i = state.selectable.length - 1; i >= 0; i--) { 122 | const lesson = state.selectable[i]; 123 | // make initial selected lesson look different from others 124 | if (lesson.classNo === selectedLesson.classNo) { 125 | lesson.displayStatus = INITIAL; 126 | } else { 127 | lesson.displayStatus = GHOSTED; 128 | } 129 | } 130 | state.selected = selectedLesson; 131 | 132 | // user has picked a lesson 133 | } else { 134 | // user clicked on same lesson type 135 | if (state.selected.code === selectedLesson.code && 136 | state.selected.lessonType === selectedLesson.lessonType) { 137 | state.selected = selectedLesson; 138 | } 139 | setSelected(state); 140 | } 141 | }, 142 | // user clicked outside, put previously selected back 143 | [ON_CLICK_OUTSIDE](state) { 144 | if (state.selectable.length > 0) { 145 | setSelected(state); 146 | } 147 | }, 148 | }; 149 | 150 | export default { 151 | state, 152 | mutations, 153 | }; 154 | 155 | function resetTimetable(state) { 156 | state.userModules = []; 157 | Object.keys(state.week).forEach((day) => { 158 | state.week[day] = []; 159 | }); 160 | } 161 | 162 | function snakeCaseToCamelCase(text) { 163 | return text.replace(/_\w/g, m => m[1].toUpperCase()); 164 | } 165 | 166 | function setSelected(state) { 167 | const classNo = state.selected.classNo; 168 | state.selectable.forEach((lesson) => { 169 | if (lesson.classNo !== classNo) { 170 | lesson.displayStatus = HIDDEN; 171 | } else { 172 | lesson.displayStatus = SELECTED; 173 | } 174 | }); 175 | // reset state 176 | state.selectable = []; 177 | } 178 | 179 | function sortByLengthDescending(week) { 180 | Object.keys(week).forEach((name) => { 181 | week[name].sort((a, b) => b.hours - a.hours); 182 | }); 183 | } 184 | 185 | function allocateLessons(state, module) { 186 | if (Array.isArray(module.timetable)) { 187 | const lessons = {}; 188 | // came from api, no processing done yet, so let's get to work 189 | for (let i = module.timetable.length - 1; i >= 0; i--) { 190 | const lesson = createLesson(module.timetable[i], module.code); 191 | insertLessonByType(lessons, lesson); 192 | 193 | // add to the timetable 194 | state.week[lesson.dayText].push(lesson); 195 | } 196 | // set those with only one choice as displayStatus 'ONLY' 197 | Object.values(lessons).forEach((listOfLessons) => { 198 | const classNo = listOfLessons[0].classNo; 199 | if (listOfLessons.every(lesson => lesson.classNo === classNo)) { 200 | listOfLessons.forEach((lesson) => { 201 | lesson.displayStatus = ONLY; 202 | }); 203 | } 204 | }); 205 | // replace with the sorted version 206 | module.timetable = lessons; 207 | } else { 208 | // came from forage, just restore back data 209 | Object.values(module.timetable).forEach((listOfLessons) => { 210 | for (let i = listOfLessons.length - 1; i >= 0; i--) { 211 | const lesson = listOfLessons[i]; 212 | state.week[lesson.dayText].push(lesson); 213 | } 214 | }); 215 | } 216 | // Object.freeze(module.Timetable) 217 | // Object.freeze(module) 218 | } 219 | 220 | function insertLessonByType(categorizedLessons, lesson) { 221 | const lessonType = lesson.lessonType; 222 | // if lessonType is already in the categorizedLessons object 223 | if ({}.hasOwnProperty.call(categorizedLessons, lessonType)) { 224 | // check if this lesson also belongs to the one selected previously 225 | const selectedClassNo = categorizedLessons[lessonType][0].classNo; 226 | if (lesson.classNo === selectedClassNo) { 227 | lesson.displayStatus = SELECTED; 228 | } else { 229 | lesson.displayStatus = HIDDEN; 230 | } 231 | categorizedLessons[lessonType].push(lesson); 232 | // add lessonType to categorizedLessons, and add lesson to it 233 | } else { 234 | // first lesson to be added will be selected 235 | lesson.displayStatus = SELECTED; 236 | categorizedLessons[lessonType] = [lesson]; 237 | } 238 | } 239 | 240 | function createLesson(data, code) { 241 | const lesson = {}; 242 | Object.keys(data).forEach((key) => { 243 | lesson[snakeCaseToCamelCase(key)] = data[key]; 244 | }); 245 | 246 | // if weekText is too long, and contains commas 247 | if (lesson.weekText === 'Every week') { 248 | lesson.weekText = ''; 249 | } 250 | 251 | // convert 18:00:00 to 1800 format 252 | lesson.startTime = lesson.startTime.slice(0, 5).replace(':', ''); 253 | lesson.endTime = lesson.endTime.slice(0, 5).replace(':', ''); 254 | // set num of hours for lesson 255 | lesson.hours = calculateHours(lesson.startTime, lesson.endTime); 256 | 257 | lesson.code = code; 258 | lesson.lessonType = lesson.lessonType.toUpperCase(); 259 | lesson.dayText = lesson.dayText.toLowerCase(); 260 | 261 | // set uid for tracking 262 | lesson.uid = code + lesson.lessonType + lesson.classNo + lesson.venue; 263 | // these properties no longer change, so Vue will optimize 264 | /* 265 | for (let property in lesson) { 266 | Object.freeze(lesson[property]); 267 | } 268 | */ 269 | return lesson; 270 | } 271 | 272 | function calculateHours(startTime, endTime) { 273 | const startMinutes = startTime.slice(2); 274 | const hour = parseInt(endTime.slice(0, 2), 10) - parseInt(startTime.slice(0, 2), 10); 275 | const minutes = parseInt(endTime.slice(2), 10) - parseInt(startMinutes, 10); 276 | return hour + (minutes / 60); 277 | } 278 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/li-kai/modify/f5de40f63e25e932e912eec344e71892a11c3529/static/.gitkeep -------------------------------------------------------------------------------- /test/e2e/custom-assertions/elementCount.js: -------------------------------------------------------------------------------- 1 | // A custom Nightwatch assertion. 2 | // the name of the method is the filename. 3 | // can be used in tests like this: 4 | // 5 | // browser.assert.elementCount(selector, count) 6 | // 7 | // for how to write custom assertions see 8 | // http://nightwatchjs.org/guide#writing-custom-assertions 9 | exports.assertion = function (selector, count) { 10 | this.message = 'Testing if element <' + selector + '> has count: ' + count 11 | this.expected = count 12 | this.pass = function (val) { 13 | return val === this.expected 14 | } 15 | this.value = function (res) { 16 | return res.value 17 | } 18 | this.command = function (cb) { 19 | var self = this 20 | return this.api.execute(function (selector) { 21 | return document.querySelectorAll(selector).length 22 | }, [selector], function (res) { 23 | cb.call(self, res) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | require('babel-register') 2 | 3 | // http://nightwatchjs.org/guide#settings-file 4 | module.exports = { 5 | "src_folders": ["test/e2e/specs"], 6 | "output_folder": "test/e2e/reports", 7 | "custom_assertions_path": ["test/e2e/custom-assertions"], 8 | 9 | "selenium": { 10 | "start_process": true, 11 | "server_path": "node_modules/selenium-server/lib/runner/selenium-server-standalone-2.53.0.jar", 12 | "host": "127.0.0.1", 13 | "port": 4444, 14 | "cli_args": { 15 | "webdriver.chrome.driver": require('chromedriver').path 16 | } 17 | }, 18 | 19 | "test_settings": { 20 | "default": { 21 | "selenium_port": 4444, 22 | "selenium_host": "localhost", 23 | "silent": true 24 | }, 25 | 26 | "chrome": { 27 | "desiredCapabilities": { 28 | "browserName": "chrome", 29 | "javascriptEnabled": true, 30 | "acceptSslCerts": true 31 | } 32 | }, 33 | 34 | "firefox": { 35 | "desiredCapabilities": { 36 | "browserName": "firefox", 37 | "javascriptEnabled": true, 38 | "acceptSslCerts": true 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/e2e/runner.js: -------------------------------------------------------------------------------- 1 | // 1. start the dev server using production config 2 | process.env.NODE_ENV = 'testing' 3 | var server = require('../../build/dev-server.js') 4 | 5 | // 2. run the nightwatch test suite against it 6 | // to run in additional browsers: 7 | // 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings" 8 | // 2. add it to the --env flag below 9 | // or override the environment flag, for example: `npm run e2e -- --env chrome,firefox` 10 | // For more information on Nightwatch's config file, see 11 | // http://nightwatchjs.org/guide#settings-file 12 | var opts = process.argv.slice(2) 13 | if (opts.indexOf('--config') === -1) { 14 | opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']) 15 | } 16 | if (opts.indexOf('--env') === -1) { 17 | opts = opts.concat(['--env', 'chrome']) 18 | } 19 | 20 | var spawn = require('cross-spawn') 21 | var runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) 22 | 23 | runner.on('exit', function (code) { 24 | server.close() 25 | process.exit(code) 26 | }) 27 | 28 | runner.on('error', function (err) { 29 | server.close() 30 | throw err 31 | }) 32 | -------------------------------------------------------------------------------- /test/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // For authoring Nightwatch tests, see 2 | // http://nightwatchjs.org/guide#usage 3 | 4 | module.exports = { 5 | 'default e2e tests': function (browser) { 6 | browser 7 | .url('http://localhost:8080') 8 | .waitForElementVisible('#app', 5000) 9 | .assert.elementPresent('.logo') 10 | .assert.containsText('h1', 'Hello World!') 11 | .assert.elementCount('p', 3) 12 | .end() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | // Polyfill fn.bind() for PhantomJS 2 | /* eslint-disable no-extend-native */ 3 | Function.prototype.bind = require('function-bind') 4 | 5 | // require all test files (files that ends with .spec.js) 6 | var testsContext = require.context('./specs', true, /\.spec$/) 7 | testsContext.keys().forEach(testsContext) 8 | 9 | // require all src files except main.js for coverage. 10 | // you can also change this to match only the subset of files that 11 | // you want coverage for. 12 | var srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/) 13 | srcContext.keys().forEach(srcContext) 14 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | // This is a karma config file. For more details see 2 | // http://karma-runner.github.io/0.13/config/configuration-file.html 3 | // we are also using it with karma-webpack 4 | // https://github.com/webpack/karma-webpack 5 | 6 | var path = require('path') 7 | var merge = require('webpack-merge') 8 | var baseConfig = require('../../build/webpack.base.conf') 9 | var utils = require('../../build/utils') 10 | var webpack = require('webpack') 11 | var projectRoot = path.resolve(__dirname, '../../') 12 | 13 | var webpackConfig = merge(baseConfig, { 14 | // use inline sourcemap for karma-sourcemap-loader 15 | module: { 16 | loaders: utils.styleLoaders() 17 | }, 18 | devtool: '#inline-source-map', 19 | vue: { 20 | loaders: { 21 | js: 'isparta' 22 | } 23 | }, 24 | plugins: [ 25 | new webpack.DefinePlugin({ 26 | 'process.env': require('../../config/test.env') 27 | }) 28 | ] 29 | }) 30 | 31 | // no need for app entry during tests 32 | delete webpackConfig.entry 33 | 34 | // make sure isparta loader is applied before eslint 35 | webpackConfig.module.preLoaders = webpackConfig.module.preLoaders || [] 36 | webpackConfig.module.preLoaders.unshift({ 37 | test: /\.js$/, 38 | loader: 'isparta', 39 | include: path.resolve(projectRoot, 'src') 40 | }) 41 | 42 | // only apply babel for test files when using isparta 43 | webpackConfig.module.loaders.some(function (loader, i) { 44 | if (loader.loader === 'babel') { 45 | loader.include = path.resolve(projectRoot, 'test/unit') 46 | return true 47 | } 48 | }) 49 | 50 | module.exports = function (config) { 51 | config.set({ 52 | // to run in additional browsers: 53 | // 1. install corresponding karma launcher 54 | // http://karma-runner.github.io/0.13/config/browsers.html 55 | // 2. add it to the `browsers` array below. 56 | browsers: ['PhantomJS'], 57 | frameworks: ['mocha', 'sinon-chai'], 58 | reporters: ['spec', 'coverage'], 59 | files: ['./index.js'], 60 | preprocessors: { 61 | './index.js': ['webpack', 'sourcemap'] 62 | }, 63 | webpack: webpackConfig, 64 | webpackMiddleware: { 65 | noInfo: true 66 | }, 67 | coverageReporter: { 68 | dir: './coverage', 69 | reporters: [ 70 | { type: 'lcov', subdir: '.' }, 71 | { type: 'text-summary' } 72 | ] 73 | } 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /test/unit/specs/Hello.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Hello from 'src/components/Hello' 3 | 4 | describe('Hello.vue', () => { 5 | it('should render correct contents', () => { 6 | const vm = new Vue({ 7 | template: '
', 8 | components: { Hello } 9 | }).$mount() 10 | expect(vm.$el.querySelector('.hello h1').textContent).to.contain('Hello World!') 11 | }) 12 | }) 13 | --------------------------------------------------------------------------------