diff --git a/.env b/.env new file mode 100644 index 0000000..c0d6652 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +NODE_ENV=development diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ac50f1a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +node_modules/** +build/** diff --git a/.eslintrc b/.eslintrc index 3736aa1..fb12038 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,10 +15,5 @@ "new-cap": [ 0 ], }, "globals": { - "$": true, - "_": true, - "blist": true, - "jQuery": true, - "__ENV__": true } -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index 567527e..23f0139 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ build npm-debug.log # Application specific -src/constants/configurations.yml +src/constants/configurations.production.yml +src/constants/configurations.development.yml diff --git a/package.json b/package.json index 650a0f0..12f21f0 100644 --- a/package.json +++ b/package.json @@ -4,29 +4,50 @@ "description": "CDC Disease Indicator Tool", "main": "index.js", "scripts": { - "build": "webpack --optimize-minimize", - "dev-build": "webpack", - "start": "webpack-dev-server --inline --hot", + "build": "better-npm-run build", + "build:dev": "better-npm-run build:dev", + "start": "better-npm-run start", "test": "echo \"Error: no test specified\" && exit 1" }, + "betterScripts": { + "build": { + "command": "webpack", + "env": { + "NODE_ENV": "production" + } + }, + "build:dev": { + "command": "webpack", + "env": { + "NODE_ENV": "development" + } + }, + "start": { + "command": "webpack-dev-server --inline --hot", + "env": { + "NODE_ENV": "development" + } + } + }, "repository": { "type": "git", "url": "git+https://github.com/socrata/cdc-indicator.git" }, - "author": "James Chuang & Austin Valeske", + "author": "Hiko Naito, Socrata", "license": "ISC", "bugs": { "url": "https://github.com/socrata/cdc-indicator/issues" }, "homepage": "https://github.com/socrata/cdc-indicator#readme", "devDependencies": { - "autoprefixer": "^6.5.0", "babel-core": "^6.10.4", "babel-eslint": "^6.1.0", "babel-loader": "^6.2.4", "babel-preset-es2015": "^6.9.0", "babel-preset-react": "^6.11.1", + "better-npm-run": "0.0.13", "css-loader": "^0.25.0", + "cssnano": "^3.8.0", "eslint": "^2.13.1", "eslint-config-airbnb": "^9.0.1", "eslint-loader": "^1.3.0", @@ -43,9 +64,7 @@ "react-modal": "^1.5.2", "style-loader": "^0.13.1", "webpack": "^1.13.1", - "webpack-combine-loaders": "^2.0.0", "webpack-dev-server": "^1.14.1", - "webpack-merge": "^0.14.1", "yaml-loader": "^0.4.0" }, "dependencies": { diff --git a/src/actions/config.js b/src/actions/config.js new file mode 100644 index 0000000..c5024b1 --- /dev/null +++ b/src/actions/config.js @@ -0,0 +1,206 @@ +import { FETCH_CONFIG, + USER_CONFIGURABLE_OPTIONS, + CONFIG } from '../constants'; +import _ from 'lodash'; +import Soda from '../lib/Soda'; + +function setConfigurations(responses) { + const [appConfig, + filterConfig, + filters, + yearConfig, + chartConfig, + dataSourceConfig] = responses; + + let config; + + // verify we received critical part of response + if (_.isArray(appConfig)) { + config = appConfig[0] || undefined; + } + + // do not continue if we did not receive expected data + if (config === undefined) { + return { + type: FETCH_CONFIG, + config + }; + } + + // re-label some keys since SODA always use _ + const newFilterConfig = filterConfig.map((row, i) => { + const defaultValue = _.find(filters[i], { [row.value_column]: row.default_value }); + return Object.assign({}, row, { + name: row.value_column, + defaultValue: row.default_value, + defaultLabel: defaultValue[row.label_column] + }); + }); + + // iterate over filter configuration to transform filter values + // order of filterConfig and filters correspond to each other + filters.forEach((filter, i) => { + // if there is a group by specified, pub options into optionGroups array + if (filterConfig[i].group_by) { + const groupedData = _.groupBy(filter, filterConfig[i].group_by); + newFilterConfig[i].optionGroups = _.map(groupedData, (data, key) => { + return { + text: key, + options: data.map((row) => { + return { + text: row[filterConfig[i].label_column], + value: row[filterConfig[i].value_column] + }; + }) + }; + }); + } else { + const options = filter.map((row) => { + return { + text: row[filterConfig[i].label_column], + value: row[filterConfig[i].value_column] + }; + }); + + // pull default value and put it as first element + const defaultValue = _.find(options, { value: newFilterConfig[i].defaultValue }); + + newFilterConfig[i].options = options.filter((row) => + row.value !== newFilterConfig[i].defaultValue + ); + newFilterConfig[i].options.unshift(defaultValue); + } + }); + + // set latest year and year range to query data for + const latestYear = yearConfig.map((row) => +row.year).sort().pop(); + const fromYear = latestYear - (+(config.data_points || 10)) + 1; + + // set data source object + const dataSources = _.keyBy(dataSourceConfig, 'questionid'); + + config = Object.assign(config, { + filterConfig: newFilterConfig, + chartConfig, + latestYear, + fromYear, + dataSources + }); + + return { + type: FETCH_CONFIG, + config + }; +} + +export function fetchAppConfigurations() { + // application configurations + const configPromise = (!CONFIG.data.useConfigurationDatasets) ? + Promise.resolve(USER_CONFIGURABLE_OPTIONS.app) : + new Soda({ + appToken: CONFIG.data.appToken, + hostname: CONFIG.data.host, + useSecure: true + }) + .dataset(CONFIG.data.appConfigDatasetId) + .limit(1) + .fetchData(); + + // filter configurations + const filterConfigPromise = (!CONFIG.data.useConfigurationDatasets) ? + Promise.resolve(USER_CONFIGURABLE_OPTIONS.filter) : + new Soda({ + appToken: CONFIG.data.appToken, + hostname: CONFIG.data.host, + useSecure: true + }) + .dataset(CONFIG.data.filterConfigDatasetId) + .order('sort') + .fetchData(); + + // visualization configurations + const chartConfigPromise = (!CONFIG.data.useConfigurationDatasets) ? + Promise.resolve(USER_CONFIGURABLE_OPTIONS.chart) : + new Soda({ + appToken: CONFIG.data.appToken, + hostname: CONFIG.data.host, + useSecure: true + }) + .dataset(CONFIG.data.chartConfigDatasetId) + .where('published=true') + .order('sort') + .fetchData(); + + // indicator data sources configurations + const dataSourcesPromise = (!CONFIG.data.useConfigurationDatasets) ? + Promise.resolve(USER_CONFIGURABLE_OPTIONS.indicators) : + new Soda({ + appToken: CONFIG.data.appToken, + hostname: CONFIG.data.host, + useSecure: true + }) + .dataset(CONFIG.data.indicatorsConfigDatasetId) + .fetchData(); + + // actual filter values based on data + const filterPromise = filterConfigPromise + .then((response) => { + // continue to make data requests to populate filter dropdown + const promiseArray = response.map((row) => { + const columnArray = [row.value_column, row.label_column]; + + if (row.group_by) { + columnArray.unshift(row.group_by); + } + + return new Soda({ + appToken: CONFIG.data.appToken, + hostname: CONFIG.data.host, + useSecure: true + }) + .dataset(CONFIG.data.datasetId) + .select(columnArray) + .where([{ + column: row.label_column, + operator: 'IS NOT NULL' + }, { + column: row.value_column, + operator: 'IS NOT NULL' + }]) + .group(columnArray) + .order(row.label_column) + .fetchData(); + }); + + return Promise.all(promiseArray); + }); + + // list of years + const yearPromise = new Soda({ + appToken: CONFIG.data.appToken, + hostname: CONFIG.data.host, + useSecure: true + }) + .dataset(CONFIG.data.datasetId) + .where({ + column: 'year', + operator: 'IS NOT NULL' + }) + .select('year') + .group('year') + .fetchData(); + + return (dispatch) => { + Promise.all([ + configPromise, + filterConfigPromise, + filterPromise, + yearPromise, + chartConfigPromise, + dataSourcesPromise + ]) + .then((responses) => { + dispatch(setConfigurations(responses)); + }); + }; +} diff --git a/src/actions/index.js b/src/actions/index.js index b919176..3c35ff3 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -1,15 +1,17 @@ import { FETCH_DATA, - FETCH_CONFIG, UPDATE_FILTER_VALUE, UPDATE_FILTER_LABEL, SET_MAP_ELEMENT, FETCH_MAP_DATA, GEOJSON, - USER_CONFIGURABLE_OPTIONS, CONFIG } from '../constants'; import _ from 'lodash'; import Soda from '../lib/Soda'; +import { fetchAppConfigurations } from './config'; + +export { fetchAppConfigurations }; + function setFilterValue(key, value) { return { type: UPDATE_FILTER_VALUE, @@ -159,203 +161,3 @@ export function fetchMapData(filter, year) { }; } -function setConfigurations(responses) { - const [appConfig, - filterConfig, - filters, - yearConfig, - chartConfig, - dataSourceConfig] = responses; - - let config; - - // verify we received critical part of response - if (_.isArray(appConfig)) { - config = appConfig[0] || undefined; - } - - // do not continue if we did not receive expected data - if (config === undefined) { - return { - type: FETCH_CONFIG, - config - }; - } - - // re-label some keys since SODA always use _ - const newFilterConfig = filterConfig.map((row, i) => { - const defaultValue = _.find(filters[i], { [row.value_column]: row.default_value }); - return Object.assign({}, row, { - name: row.value_column, - defaultValue: row.default_value, - defaultLabel: defaultValue[row.label_column] - }); - }); - - // iterate over filter configuration to transform filter values - // order of filterConfig and filters correspond to each other - filters.forEach((filter, i) => { - // if there is a group by specified, pub options into optionGroups array - if (filterConfig[i].group_by) { - const groupedData = _.groupBy(filter, filterConfig[i].group_by); - newFilterConfig[i].optionGroups = _.map(groupedData, (data, key) => { - return { - text: key, - options: data.map((row) => { - return { - text: row[filterConfig[i].label_column], - value: row[filterConfig[i].value_column] - }; - }) - }; - }); - } else { - const options = filter.map((row) => { - return { - text: row[filterConfig[i].label_column], - value: row[filterConfig[i].value_column] - }; - }); - - // pull default value and put it as first element - const defaultValue = _.find(options, { value: newFilterConfig[i].defaultValue }); - - newFilterConfig[i].options = options.filter((row) => - row.value !== newFilterConfig[i].defaultValue - ); - newFilterConfig[i].options.unshift(defaultValue); - } - }); - - // set latest year and year range to query data for - const latestYear = yearConfig.map((row) => +row.year).sort().pop(); - const fromYear = latestYear - (+(config.data_points || 10)) + 1; - - // set data source object - const dataSources = _.keyBy(dataSourceConfig, 'questionid'); - - config = Object.assign(config, { - filterConfig: newFilterConfig, - chartConfig, - latestYear, - fromYear, - dataSources - }); - - return { - type: FETCH_CONFIG, - config - }; -} - -export function fetchAppConfigurations() { - // application configurations - const configPromise = (!CONFIG.data.useConfigurationDatasets) ? - Promise.resolve(USER_CONFIGURABLE_OPTIONS.app) : - new Soda({ - appToken: CONFIG.data.appToken, - hostname: CONFIG.data.host, - useSecure: true - }) - .dataset(CONFIG.data.appConfigDatasetId) - .limit(1) - .fetchData(); - - // filter configurations - const filterConfigPromise = (!CONFIG.data.useConfigurationDatasets) ? - Promise.resolve(USER_CONFIGURABLE_OPTIONS.filter) : - new Soda({ - appToken: CONFIG.data.appToken, - hostname: CONFIG.data.host, - useSecure: true - }) - .dataset(CONFIG.data.filterConfigDatasetId) - .order('sort') - .fetchData(); - - // visualization configurations - const chartConfigPromise = (!CONFIG.data.useConfigurationDatasets) ? - Promise.resolve(USER_CONFIGURABLE_OPTIONS.chart) : - new Soda({ - appToken: CONFIG.data.appToken, - hostname: CONFIG.data.host, - useSecure: true - }) - .dataset(CONFIG.data.chartConfigDatasetId) - .where('published=true') - .order('sort') - .fetchData(); - - // indicator data sources configurations - const dataSourcesPromise = (!CONFIG.data.useConfigurationDatasets) ? - Promise.resolve(USER_CONFIGURABLE_OPTIONS.indicators) : - new Soda({ - appToken: CONFIG.data.appToken, - hostname: CONFIG.data.host, - useSecure: true - }) - .dataset(CONFIG.data.indicatorsConfigDatasetId) - .fetchData(); - - // actual filter values based on data - const filterPromise = filterConfigPromise - .then((response) => { - // continue to make data requests to populate filter dropdown - const promiseArray = response.map((row) => { - const columnArray = [row.value_column, row.label_column]; - - if (row.group_by) { - columnArray.unshift(row.group_by); - } - - return new Soda({ - appToken: CONFIG.data.appToken, - hostname: CONFIG.data.host, - useSecure: true - }) - .dataset(CONFIG.data.datasetId) - .select(columnArray) - .where([{ - column: row.label_column, - operator: 'IS NOT NULL' - }, { - column: row.value_column, - operator: 'IS NOT NULL' - }]) - .group(columnArray) - .order(row.label_column) - .fetchData(); - }); - - return Promise.all(promiseArray); - }); - - // list of years - const yearPromise = new Soda({ - appToken: CONFIG.data.appToken, - hostname: CONFIG.data.host, - useSecure: true - }) - .dataset(CONFIG.data.datasetId) - .where({ - column: 'year', - operator: 'IS NOT NULL' - }) - .select('year') - .group('year') - .fetchData(); - - return (dispatch) => { - Promise.all([ - configPromise, - filterConfigPromise, - filterPromise, - yearPromise, - chartConfigPromise, - dataSourcesPromise - ]) - .then((responses) => { - dispatch(setConfigurations(responses)); - }); - }; -} diff --git a/src/components/Chart.Bar.jsx b/src/components/Chart.Bar.jsx index e1a5240..364feee 100644 --- a/src/components/Chart.Bar.jsx +++ b/src/components/Chart.Bar.jsx @@ -1,4 +1,5 @@ import React, { PropTypes } from 'react'; +import _ from 'lodash'; import ChartData from '../lib/ChartData'; import C3ChartUpdatable from './C3ChartUpdatable'; import styles from '../styles/spinner.css'; diff --git a/src/constants/index.js b/src/constants/index.js index 9bd775f..15a8b43 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -6,8 +6,10 @@ const SET_MAP_ELEMENT = 'SET_MAP_ELEMENT'; const UPDATE_FILTER_VALUE = 'UPDATE_FILTER_VALUE'; const UPDATE_FILTER_LABEL = 'UPDATE_FILTER_LABEL'; -// load application configuration parameters -import CONFIG from './configurations.yml'; +import CONFIG_DEV from './configurations.development.yml'; +import CONFIG_PROD from './configurations.production.yml'; + +const CONFIG = (process.env.NODE_ENV === 'production') ? CONFIG_PROD : CONFIG_DEV; // load local visualizations configurations import USER_CONFIGURABLE_OPTIONS from './userConfigurableOptions.yml'; diff --git a/src/constants/userConfigurableOptions.yml b/src/constants/userConfigurableOptions.yml index 1d3106b..66d7fae 100644 --- a/src/constants/userConfigurableOptions.yml +++ b/src/constants/userConfigurableOptions.yml @@ -78,7 +78,7 @@ chart: type: map indicators: - data_label: HRQOL - data_link: https://chronicdata.cdc.gov/Health-Related-Quality-of-Life/Behavioral-Risk-Factor-Data-Health-Related-Quality/xuxn-8kju + data_link: https://chronicdata.cdc.gov/d/xuxn-8kju questionid: AL002 source_label: BRFSS source_link: http://www.cdc.gov/brfss/ diff --git a/src/index.jsx b/src/main.jsx similarity index 100% rename from src/index.jsx rename to src/main.jsx diff --git a/webpack.config.js b/webpack.config.js index caf4efb..39be3fd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,34 +1,18 @@ 'use strict'; -const path = require('path'); const webpack = require('webpack'); +const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); -const combineLoaders = require('webpack-combine-loaders'); -const autoprefixer = require('autoprefixer'); -const merge = require('webpack-merge'); +const cssnano = require('cssnano'); + +const __ENV__ = process.env.NODE_ENV || 'development'; +const __PROD__ = 'production' === __ENV__; + +console.log(`NODE_ENV set to ${__ENV__}`); // common webpack configurations for both build and webpack-dev-server -const common = { - entry: [ - './src/index.jsx' - ], - output: { - filename: 'app.js', - path: path.join(__dirname, 'build') - }, - // Webpack will watch your files and when one of them changes, - // it will immediately rerun the build and recreate your output file. - watch: true, - plugins: [ - new HtmlWebpackPlugin({ - filename: 'index.html', - template: 'src/index.template.html', - inject: true - }), - // extract CSS modules to a separate file - new ExtractTextPlugin('app.css') - ], +const webpackConfig = { module: { preLoaders: [ { @@ -38,10 +22,11 @@ const common = { loaders: ['eslint'], } ], + // add JavaScript/JSON/YAML loaders loaders: [ { test: [/\.js$/, /\.jsx$/], - exclude: path.resolve('node_modules/'), + exclude: path.resolve('node_modules'), loader: 'babel-loader', query: { presets: ['react', 'es2015'] @@ -54,63 +39,151 @@ const common = { { test: [/\.yml$/, /\.yaml$/], loader: 'json!yaml' - }, - // process custom css (in src/styles) as CSS modules - { - test: /\.css$/, - include: path.resolve('src/styles'), - loader: ExtractTextPlugin.extract( - 'style', - combineLoaders([{ - loader: 'css', - query: { - modules: true, - localIdentName: '[name]__[local]___[hash:base64:5]', - importLoaders: 1 - } - }, { - loader: 'postcss' - }]) - ) - }, - // process other css (like vendors) normally - { - test: /\.css$/, - exclude: path.resolve('src/styles'), - loader: ExtractTextPlugin.extract('style', 'css') } ] }, - resolve: { - extensions: ['', '.js', '.jsx'] + output: { + filename: '[name].js', + path: path.resolve('build') }, - postcss() { - return [ - autoprefixer({ browsers: ['last 2 versions'] }) - ]; + // Common plugins + plugins: [ + new webpack.DefinePlugin({ + 'process.env': { + 'NODE_ENV': JSON.stringify(__ENV__) + } + }), + new HtmlWebpackPlugin({ + filename: 'index.html', + template: 'src/index.template.html', + inject: true + }) + ], + resolve: { + extensions: ['', '.js', '.jsx'], + root: path.resolve('src') } }; -let config; - -switch(process.env.npm_lifecycle_event) { - case 'build': - config = merge(common, { - plugins: [ - new webpack.NoErrorsPlugin(), - // set node env to production while running build process to minimize react - new webpack.DefinePlugin({ - 'process.env':{ - 'NODE_ENV': JSON.stringify('production') - } - }) - ] - }); - // add polyfills using .unshift() so they are added prior to entry file - config.entry.unshift('es6-promise', 'babel-polyfill', 'whatwg-fetch'); - break; - default: - config = merge(common, {}); +// Customizations based on environment + +/** + * Entry Points + */ +const APP_ENTRY = './src/main.jsx'; +// add polyfills to production code +webpackConfig.entry = { + app: (__PROD__) ? + ['es6-promise', 'babel-polyfill', 'whatwg-fetch', APP_ENTRY] : + [APP_ENTRY] +}; + +/** + * Plugins + */ +// add optimizations for production +if (__PROD__) { + webpackConfig.plugins.push( + // extract CSS modules to a separate file + new ExtractTextPlugin('[name].css', { + allChunks: true + }), + // optimize output JS + new webpack.optimize.OccurrenceOrderPlugin(), + new webpack.optimize.DedupePlugin(), + new webpack.optimize.UglifyJsPlugin({ + compress: { + unused : true, + dead_code : true, + warnings : false + } + }) + ); + +// add live development support plugins +} else { + webpackConfig.plugins.push( + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin() + ); +} + +/** + * Style Loaders + */ +const BASE_CSS_LOADER = 'css?sourceMap&-minimize'; +const CSS_MODULE = '&modules&localIdentName=[name]__[local]___[hash:base64:5]&importLoaders=0'; +const styleLoaders = [ + // process custom css (in src/styles) as CSS modules + { + test: /\.css$/, + include: path.resolve('src/styles'), + loaders: ['style', `${BASE_CSS_LOADER}${CSS_MODULE}`], + __cssModules: true + }, + // process other css (like vendors) normally + { + test: /\.css$/, + exclude: path.resolve('src/styles'), + loaders: ['style', BASE_CSS_LOADER], + } +]; + +// Enable ExtractTextPlugin and postcss on production +if (__PROD__) { + styleLoaders.forEach((styleLoader) => { + let [first, ...rest] = styleLoader.loaders; + + // find css-loader so we can add right 'importLoaders' parameter for CSS Modules + if (styleLoader.__cssModules) { + rest = [].concat(rest).map((loader) => { + if (/^css/.test(loader.split('?')[0])) { + const [name, params] = loader.split('?', 2); + const newParams = params.split('&').map((param) => { + if ('importLoaders' === param.split('=')[0]) { + return `importLoaders=${rest.length}`; + } + return param; + }); + return `${name}?${newParams.join('&')}`; + } + return loader; + }); + delete styleLoader.__cssModules; + } + + // add postcss loader (joined later) + rest.push('postcss'); + + // enable ExtractTextPlugin + styleLoader.loader = ExtractTextPlugin.extract(first, rest.join('!')); + delete styleLoader.loaders; + }); + + webpackConfig.postcss = [ + cssnano({ + autoprefixer : { + add : true, + remove : true, + browsers : ['last 2 versions'] + }, + discardComments : { + removeAll : true + }, + discardUnused : false, + mergeIdents : false, + reduceIdents : false, + safe : true, + sourcemap : true + }) + ]; +} + +webpackConfig.module.loaders.push(...styleLoaders); + +// Dev only - support hot reloading +if (!__PROD__) { + webpackConfig.watch = true; } -module.exports = config; +module.exports = webpackConfig;