diff --git a/.dir-locals.el b/.dir-locals.el
index 5be90735d93c98fc486e420c6b8687352d8c762b..37455266a5aa0169fe3e952dd09a19d1746fb82a 100644
--- a/.dir-locals.el
+++ b/.dir-locals.el
@@ -1,10 +1,11 @@
 ((clojure-mode . ((eval . (progn
                             ;; Specify which arg is the docstring for certain macros
                             ;; (Add more as needed)
-                            (put 'defannotation 'clojure-doc-string-elt 2)
                             (put 'defendpoint 'clojure-doc-string-elt 3)
+                            (put 'api/defendpoint 'clojure-doc-string-elt 3)
                             (put 'defsetting 'clojure-doc-string-elt 2)
                             (put 'setting/defsetting 'clojure-doc-string-elt 2)
+                            (put 's/defn 'clojure-doc-string-elt 2)
 
                             ;; Define custom indentation for functions inside metabase.
                             ;; This list isn't complete; add more forms as we come across them.
diff --git a/README.md b/README.md
index 5928a072353ba2c469e5aa2e4406f71779dc1960..72df58721cb5638b7c138df380258a7a225df5ab 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Metabase is the easy, open source way for everyone in your company to ask questi
 [![NPM Dependencies Status](https://david-dm.org/metabase/metabase.svg)](https://david-dm.org/metabase/metabase)
 [![Issue Stats](http://issuestats.com/github/metabase/metabase/badge/pr)](http://issuestats.com/github/metabase/metabase)
 [![Issue Stats](http://issuestats.com/github/metabase/metabase/badge/issue)](http://issuestats.com/github/metabase/metabase)
-
+ 
 # Features
 - 5 minute [setup](http://www.metabase.com/docs/latest/setting-up-metabase) (We're not kidding)
 - Let anyone on your team [ask questions](http://www.metabase.com/docs/latest/users-guide/03-asking-questions) without knowing SQL
@@ -34,7 +34,7 @@ For more information check out [metabase.com](http://www.metabase.com)
 - Google BigQuery
 - SQLite
 - H2
-- Crate
+- CrateDB
 - Oracle
 - Vertica
 
diff --git a/bin/build b/bin/build
index 9f54a082e714c626dee8f99a05a932e3473f978b..aa4388776d2609975bd21f58c3c752f712c1fc6f 100755
--- a/bin/build
+++ b/bin/build
@@ -20,13 +20,13 @@ frontend-deps() {
 }
 
 frontend() {
-    echo "Running 'webpack -p' to assemble and minify frontend assets..." &&
-    ./node_modules/.bin/webpack -p --bail
+    echo "Running 'webpack' with NODE_ENV=production assemble and minify frontend assets..." &&
+    NODE_ENV=production ./node_modules/.bin/webpack --bail
 }
 
 frontend-fast() {
-    echo "Running 'webpack' to assemble and minify frontend assets..." &&
-    ./node_modules/.bin/webpack --devtool eval --bail
+    echo "Running 'webpack' with NODE_ENV=development to assemble frontend assets..." &&
+    NODE_ENV=development ./node_modules/.bin/webpack --bail --devtool eval
 }
 
 sample-dataset() {
diff --git a/bin/ci b/bin/ci
index 47825dc2f50bfa615264607cd45a42d9e43dc630..5e082904f14de07a915d8b0b1c1d384ef41b54a6 100755
--- a/bin/ci
+++ b/bin/ci
@@ -48,13 +48,13 @@ node-6() {
     if is_enabled "jar" || is_enabled "e2e" || is_enabled "screenshots"; then
         run_step ./bin/build version frontend sample-dataset uberjar
     fi
-    # if is_enabled "e2e" || is_enabled "compare_screenshots"; then
-    #     USE_SAUCE=true \
-    #         run_step yarn run test-e2e
-    # fi
-    # if is_enabled "screenshots"; then
-    #     run_step node_modules/.bin/babel-node ./bin/compare-screenshots
-    # fi
+    if is_enabled "e2e" || is_enabled "compare_screenshots"; then
+        USE_SAUCE=true \
+            run_step yarn run test-e2e
+    fi
+    if is_enabled "screenshots"; then
+        run_step node_modules/.bin/babel-node ./bin/compare-screenshots
+    fi
 }
 
 
@@ -64,6 +64,7 @@ install-crate() {
     sudo apt-get install -y crate
     # ulimit setting refused Crate service to start on CircleCI container - so comment it
     sudo sed -i '/MAX_LOCKED_MEMORY/s/^/#/' /etc/init/crate.conf
+    echo "psql.port: 5200" | sudo tee -a /etc/crate/crate.yml
     sudo service crate restart
 }
 
diff --git a/bin/docker/run_metabase.sh b/bin/docker/run_metabase.sh
index e8379056518fbc4e5d52046caca3df8ca5dff03f..db15436f991bed0bd370a3a8aa5926aca4087bbf 100755
--- a/bin/docker/run_metabase.sh
+++ b/bin/docker/run_metabase.sh
@@ -26,7 +26,7 @@ fi
 
 
 # Setup Java Options
-JAVA_OPTS="-Dlogfile.path=target/log -XX:+CMSClassUnloadingEnabled -XX:+UseConcMarkSweepGC -server"
+JAVA_OPTS="${JAVA_OPTS} -Dlogfile.path=target/log -XX:+CMSClassUnloadingEnabled -XX:+UseConcMarkSweepGC -server"
 
 if [ ! -z "$JAVA_TIMEZONE" ]; then
   JAVA_OPTS="${JAVA_OPTS} -Duser.timezone=${JAVA_TIMEZONE}"
diff --git a/bin/version b/bin/version
index f1a7f883f2d73d8b893a9132130924eff7b0b690..5b86e7d0bf337fd7028a69de863906770a91f540 100755
--- a/bin/version
+++ b/bin/version
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-VERSION="v0.21.0-snapshot"
+VERSION="v0.22.0-snapshot"
 
 # dynamically pull more interesting stuff from latest git commit
 HASH=$(git show-ref --head --hash=7 head)            # first 7 letters of hash should be enough; that's what GitHub uses
diff --git a/deploy/aws-eb-docker/.ebextensions/extend_timeout.config b/deploy/aws-eb-docker/.ebextensions/extend_timeout.config
deleted file mode 100644
index f81a14bf2db8ec8d8510555e9740e77132601388..0000000000000000000000000000000000000000
--- a/deploy/aws-eb-docker/.ebextensions/extend_timeout.config
+++ /dev/null
@@ -1,4 +0,0 @@
-option_settings:
-  - namespace: aws:elasticbeanstalk:command
-    option_name: Timeout
-    value: 600
diff --git a/deploy/aws-eb-docker/.ebextensions/http_redirect.config b/deploy/aws-eb-docker/.ebextensions/http_redirect.config
deleted file mode 100644
index 05913d20429b3ad012a30f23086d0b09c2cb066e..0000000000000000000000000000000000000000
--- a/deploy/aws-eb-docker/.ebextensions/http_redirect.config
+++ /dev/null
@@ -1,5 +0,0 @@
-commands:
-  test_command: 
-    command: sed -i 's/location \/ {/location \/ {\nif ($http_x_forwarded_proto != "https") {\n    set $var "redirect";\n}\n\nif ($request_uri = "\/api\/health") {\n    set $var "${var}_health";\n}\n\nif ($var = 'redirect') {\n    rewrite ^ https:\/\/$host$request_uri? permanent;\n}\n/' *-proxy.conf 
-    cwd: /etc/nginx/sites-available
-    ignoreErrors: true
diff --git a/deploy/aws-eb-docker/Dockerfile b/deploy/aws-eb-docker/Dockerfile
deleted file mode 100644
index c99d8ddce4aaa1f847c2ffd80ac167fcf6aded1a..0000000000000000000000000000000000000000
--- a/deploy/aws-eb-docker/Dockerfile
+++ /dev/null
@@ -1,23 +0,0 @@
-FROM ubuntu:trusty
-
-ENV LC_ALL C
-ENV LANG C.UTF-8
-ENV DEBIAN_FRONTEND noninteractive
-ENV DEBCONF_NONINTERACTIVE_SEEN true
-ENV MB_JETTY_PORT 3000
-
-# basic update of our system + adding Java
-RUN apt-get update && \
-    apt-get install -y openjdk-7-jre
-
-# include our local build in the image
-# TODO: eventually we could probably set this up to download the jar file dynamically
-COPY ./metabase.jar /app/
-COPY ./run_metabase.sh /app/
-RUN chmod 755 /app/run_metabase.sh
-
-# make our webserver port available
-EXPOSE 3000
-
-# run it
-ENTRYPOINT ["/app/run_metabase.sh"]
diff --git a/deploy/aws-eb-docker/Dockerrun.aws.json b/deploy/aws-eb-docker/Dockerrun.aws.json
deleted file mode 100644
index 6cb23d3bc46e9bc2844f89ea8ee4c665622ae06b..0000000000000000000000000000000000000000
--- a/deploy/aws-eb-docker/Dockerrun.aws.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "AWSEBDockerrunVersion": "1",
-  "Logging": "/var/log/metabase"
-}
diff --git a/deploy/aws-eb-docker/run_metabase.sh b/deploy/aws-eb-docker/run_metabase.sh
deleted file mode 100755
index 6ad02425f0fbea22cb233269f92d49d5759464bb..0000000000000000000000000000000000000000
--- a/deploy/aws-eb-docker/run_metabase.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/bash
-
-# Metabase Web Container
-export MB_JETTY_HOST=$HOSTNAME
-# NOTE: we set MB_JETTY_PORT in our Dockerfile in order to ensure we bind to the port exposed by Docker
-
-# Metabase Database Info
-# TODO: we could make this generic by first checking if the $RDS_* env variables are available and if
-#       so then apply the code below and map them to our Metabase env variables
-export MB_DB_DBNAME=$RDS_DB_NAME
-export MB_DB_USER=$RDS_USERNAME
-export MB_DB_PASS=$RDS_PASSWORD
-export MB_DB_HOST=$RDS_HOSTNAME
-export MB_DB_PORT=$RDS_PORT
-
-# TODO: dynamically determine type, probably using the port number
-export MB_DB_TYPE=postgres
-
-exec java -Dlogfile.path=target/log -XX:+CMSClassUnloadingEnabled -XX:+UseConcMarkSweepGC -server -jar /app/metabase.jar
diff --git a/deploy/deploy_aws.sh b/deploy/deploy_aws.sh
deleted file mode 100755
index b471295cfc9d9f1688caea1908a548eab5e2dee7..0000000000000000000000000000000000000000
--- a/deploy/deploy_aws.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/bash
-# for reference
-# CIRCLE_SHA1=a3262e9b60a25e6a8a7faa29478b2b455b5ec4a3
-# CIRCLE_BRANCH=master
-
-if [ $# -ne 2 ]; then
-    echo "usage: $0 stackid appid"
-    exit 1
-fi
-
-STACKID=$1
-APPID=$2
-echo "deploying $CIRCLE_SHA1 from $CIRCLE_BRANCH ..."
-aws opsworks create-deployment --stack-id $STACKID --app-id $APPID --comment "deploying $CIRCLE_SHA1 from $CIRCLE_BRANCH" --command='{"Name": "deploy"}'
diff --git a/deploy/deploy_staging.sh b/deploy/deploy_staging.sh
deleted file mode 100755
index ecc7aeb93965352919f0e1f1bce7277efca13b93..0000000000000000000000000000000000000000
--- a/deploy/deploy_staging.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/bash
-set -eo pipefail
-
-BASEDIR=$(dirname $0)
-source "$BASEDIR/functions"
-
-EB_ENVIRONMENT=metabase-staging
-
-# deploy EB version to environment
-deploy_version ${EB_ENVIRONMENT}
diff --git a/deploy/deploy_version.sh b/deploy/deploy_version.sh
deleted file mode 100755
index ff0c07c34d155379a6e5e26bddc8d169b456ffc7..0000000000000000000000000000000000000000
--- a/deploy/deploy_version.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-set -eo pipefail
-
-BASEDIR=$(dirname $0)
-source "$BASEDIR/functions"
-
-# deploy EB version to environment
-deploy_version "$1" "$2"
diff --git a/deploy/functions b/deploy/functions
deleted file mode 100644
index 537c3d48451e826e6a2e7746851256a28de58a6d..0000000000000000000000000000000000000000
--- a/deploy/functions
+++ /dev/null
@@ -1,92 +0,0 @@
-#!/bin/bash
-set -eo pipefail
-[[ -f /root/bin/aws.sh ]] && source /root/bin/aws.sh
-
-BASEDIR=$(dirname $0)
-PROJECT_ROOT=$(cd ${BASEDIR}/..; pwd)
-
-ARTIFACTS_DIR="$PROJECT_ROOT/deploy/artifacts"
-ARTIFACTS_S3BUCKET=${S3BUCKET:=metabase-artifacts}
-
-BRANCH=$(cd ${PROJECT_ROOT}; $(which git) rev-parse --abbrev-ref HEAD)
-# OpsWorks creates a deploy branch. We'll use master in this case
-[[ "$BRANCH" == "deploy" ]] && BRANCH="master"
-
-COMMITISH=$(cd ${PROJECT_ROOT}; $(which git) rev-parse --short HEAD)
-DATE=$(date +%Y-%m-%d)
-DEFAULT_RELEASE_ZIP_FILE_NAME="metabase-$BRANCH-$DATE-$COMMITISH.zip"
-
-export LANG=en_US.UTF-8
-export LANGUAGE=$LANG
-export LC_ALL=$LANG
-
-build_uberjar() {
-    [[ "$USER" == "root" ]] && export LEIN_ROOT=true
-    $(which locale) | $(which sort) || true
-
-    echo "building uberjar"
-    ${PROJECT_ROOT}/bin/build
-}
-
-upload_release_artifacts() {
-    $(which locale) | $(which sort) || true
-
-    echo "uploading $ARTIFACTS_DIR/*.jar -> $ARTIFACTS_S3BUCKET/jar/"
-    aws s3 cp $ARTIFACTS_DIR/ s3://$ARTIFACTS_S3BUCKET/jar/ --recursive --exclude "*" --include "*.jar"
-    echo "uploading $ARTIFACTS_DIR/*.zip -> $ARTIFACTS_S3BUCKET/eb/"
-    aws s3 cp $ARTIFACTS_DIR/ s3://$ARTIFACTS_S3BUCKET/eb/ --recursive --exclude "*" --include "*.zip"
-}
-
-mk_release_artifacts() {
-    METABASE_JAR_NAME="metabase.jar"
-    RELEASE_TYPE="aws-eb-docker"
-    RELEASE_JAR_FILE_NAME=${METABASE_JAR_NAME%.jar}-$BRANCH-$DATE-$COMMITISH.jar
-    RELEASE_ZIP_FILE_NAME="$1"
-    UBERJAR_DIR="${PROJECT_ROOT}/target/uberjar"
-
-    if [[ -z $RELEASE_ZIP_FILE_NAME ]]; then
-        RELEASE_ZIP_FILE_NAME=$DEFAULT_RELEASE_ZIP_FILE_NAME
-        echo "release name not provided defaulting to $RELEASE_ZIP_FILE_NAME"
-    fi
-
-    RELEASE_FILES="${PROJECT_ROOT}/deploy/${RELEASE_TYPE}"
-    RELEASE_FILE="${PROJECT_ROOT}/${RELEASE_ZIP_FILE_NAME}"
-
-    # package up the release files
-    cd $RELEASE_FILES; zip -r $RELEASE_FILE * .ebextensions
-
-    # add the built uberjar
-    cd $UBERJAR_DIR; zip $RELEASE_FILE $METABASE_JAR_NAME
-
-    mkdir -p $ARTIFACTS_DIR
-    rm -f $ARTIFACTS_DIR/*
-    mv -f $RELEASE_FILE $ARTIFACTS_DIR/
-    mv -f $UBERJAR_DIR/$METABASE_JAR_NAME $ARTIFACTS_DIR/$RELEASE_JAR_FILE_NAME
-
-    upload_release_artifacts
-}
-
-create_eb_version() {
-    EB_APPLICATION=Metabase
-    EB_VERSION_LABEL=$1
-    S3_KEY=$2
-
-    $(which locale) | $(which sort) || true
-
-    [[ -z "$EB_VERSION_LABEL" ]] && EB_VERSION_LABEL="$BRANCH-$DATE-$COMMITISH"
-    [[ -z "$S3_KEY" ]] && S3_KEY=$DEFAULT_RELEASE_ZIP_FILE_NAME
-
-    echo "Creating app version in EB"
-    aws elasticbeanstalk create-application-version --no-auto-create-application --region us-east-1 --application-name ${EB_APPLICATION} --version-label ${EB_VERSION_LABEL} --source-bundle S3Bucket="${ARTIFACTS_S3BUCKET}",S3Key="eb/${S3_KEY}"
-}
-
-deploy_version() {
-    EB_ENVIRONMENT=$1
-    EB_VERSION_LABEL=$2
-
-    $(which locale) | $(which sort) || true
-
-    [[ -z "$EB_ENVIRONMENT" ]] && EB_VERSION_LABEL="metabase-staging" && echo ""
-    [[ -z "$EB_VERSION_LABEL" ]] && EB_VERSION_LABEL="$BRANCH-$DATE-$COMMITISH"
-    aws elasticbeanstalk update-environment --region us-east-1 --environment-name ${EB_ENVIRONMENT} --version-label ${EB_VERSION_LABEL}
-}
diff --git a/deploy/mk_release.sh b/deploy/mk_release.sh
deleted file mode 100755
index b3b5a88dbd9eff24ac17a5de80c5af1011f2eed3..0000000000000000000000000000000000000000
--- a/deploy/mk_release.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-set -eo pipefail
-
-BASEDIR=$(dirname $0)
-source "$BASEDIR/functions"
-
-build_uberjar
-mk_release_artifacts "$1"
diff --git a/deploy/upload_version.sh b/deploy/upload_version.sh
deleted file mode 100755
index 3aa6e2cefd064ebaea9192008f81c5c1babfed5b..0000000000000000000000000000000000000000
--- a/deploy/upload_version.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-set -eo pipefail
-
-BASEDIR=$(dirname $0)
-source "$BASEDIR/functions"
-
-create_eb_version "$1" "$2"
diff --git a/docs/administration-guide/01-managing-databases.md b/docs/administration-guide/01-managing-databases.md
index b25b3cc4739e5c0a5b2f53a39b39681541ddbc16..ffd6f38d51ee0bbf6d7f97aa32b4f94f325ba77f 100644
--- a/docs/administration-guide/01-managing-databases.md
+++ b/docs/administration-guide/01-managing-databases.md
@@ -14,12 +14,12 @@ Now you’ll see a list of your databases. To connect another database to Metaba
 * [Google BigQuery](databases/bigquery.md)
 * H2
 * MongoDB (version 3.0 or higher)
-* MySQL (version 4.1 or higher)
+* MySQL (version 4.1 or higher, as well as MariaDB)
 * Postgres
 * SQLite
 * SQL Server
-* Druid
-* Crate
+* Driud
+* [CrateDB](databases/cratedb.md)
 * [Oracle](databases/oracle.md)
 * [Vertica](databases/vertica.md)
 
diff --git a/docs/administration-guide/05-setting-permissions.md b/docs/administration-guide/05-setting-permissions.md
index a0918b25d57c508993a8b552eee97839c7d9f723..99bfac686e9174409593c13be08db428d2f5920f 100644
--- a/docs/administration-guide/05-setting-permissions.md
+++ b/docs/administration-guide/05-setting-permissions.md
@@ -6,7 +6,7 @@ There are always going to be sensitive bits of information in your databases and
 
 Metabase uses a group-based approach to set permissions and restrictions on your databases and tables. At a high level, to set up permissions in your Metabase instance you’ll need to create one or more groups, add members to those groups, and then choose what level of database and SQL access those groups should have.
 
-A user can be a member of multiple groups, and if one of the groups they’re in has access to a particular database, but another group they’re a member of does not, then they **will** have access to that database.
+A user can be a member of multiple groups, and if one of the groups they’re in has access to a particular database or table, but another group they’re a member of does not, then they **will** have access to that database.
 
 ### Groups
 
@@ -20,7 +20,7 @@ You’ll notice that you already have two default groups: Administrators and All
 
 You’ll also see that you’re a member of the **Administrators** group — that’s why you were able to go to the Admin Panel in the first place. So, to make someone an admin of Metabase you just need to add them to this group. Metabase admins can log into the Admin Panel and make changes there, and they always have unrestricted access to all data that you have in your Metabase instance. So be careful who you add to the Administrator group!
 
-The **All Users** group is another special one. Every Metabase user is always a  member of this group, though they can also be a member of as many other groups as you want. We recommend using the All Users group as a way to set default access levels for new Metabase users. If you have [Google single sign-on](09-single-sign-on.md) enabled, new users who join that way will be automatically added to the All Users group.
+The **All Users** group is another special one. Every Metabase user is always a  member of this group, though they can also be a member of as many other groups as you want. We recommend using the All Users group as a way to set default access levels for new Metabase users. If you have [Google single sign-on](09-single-sign-on.md) enabled, new users who join that way will be automatically added to the All Users group. (**Important note:** as we mentioned above, a user is given the *most permissive* setting she has for a given database/schema/table across *all* groups she is in. Because of that, it is important that your All Users group should never have *greater* access for an item than a group for which you're trying to restrict access — otherwise the more permissive setting will win out.)
 
 If you’ve set up the [Slack integration](08-setting-up-slack.md) and enabled [Metabot](../users-guide/10-metabot.md), you’ll also see a special **Metabot** group, which will allow you to restrict which questions your users will be able to access in Slack via Metabot.
 
@@ -77,4 +77,4 @@ Pulses act a bit differently with regard to permissions. When a user creates a n
 ---
 
 ## Next: custom segments and metrics
-Learn how to define custom segments and commonly referenced metrics in the [next section](06-segments-and-metrics.md).
+Learn how to create collections of questions to organize things and decide who gets to see what in the [next section](06-collections.md).
diff --git a/docs/administration-guide/06-collections.md b/docs/administration-guide/06-collections.md
new file mode 100644
index 0000000000000000000000000000000000000000..e9a74288c2f96b393e026a5a739a38c485de8fb9
--- /dev/null
+++ b/docs/administration-guide/06-collections.md
@@ -0,0 +1,44 @@
+## Creating Collections for Your Saved Questions
+---
+
+Collections are a great way to organize your saved questions and decide who gets to see and edit things. Collections could be things like, "Important Metrics," "Marketing KPIs," or "Questions about users." Multiple [user groups](05-setting-permissions.md) can be given access to the same collections, so we don't necessarily recommend naming collections after user groups.
+
+This page will teach you how to create and manage your collections. For more information on organizing saved questions and using collections, [check out this section of the User's Guide](../users-guide/05-sharing-answers.md).
+
+### Creating and editing collections
+Only administrators of Metabase can create and edit collections. From the Questions section of Metabase, click on the `Create a collection` button. Give your collection a name, choose a color for it, and give it a description if you'd like.
+
+![Permissions empty state](images/collections/collections-empty-state.png)
+
+### Setting permissions for collections
+Collection permissions are similar to data permissions. Rather than going to the Admin Panel, you set permissions on collections by clicking on the lock icon in the top-right of the Questions screen or the top-right of a collection screen.
+
+![Permissions grid](images/collections/permissions-grid.png)
+
+You'll see a table with your user groups along the top and all your collections down along the left. A user group can have View access, Curate access, or no access to a given collection.
+
+- View access: can see all the questions in the collection, **even if the user doesn't have access to the underlying data used to create the question.**
+- Curate access: can additionally move questions in or out of the collection, and edit the questions in the collection.
+- No access: won't see the collection listed on the Questions page, and can't see questions from this collection in dashboards or when creating a Pulse.
+
+Just like with data access permissions, collection permissions are *additive*, meaning that if a user belongs to more than one group, if one of their groups has a more restrictive setting for a collection than another one of their groups, they'll be given the *more permissive* setting. This is especially important to remember when dealing with the All Users group: since all users are members of this group, if you give the All Users group Curate access to a collection, then *all* users will be given that access for that collection, even if they also belong to a group with *less* access than that.
+
+### The "Everything Else" section
+If a question isn't saved within a collection, it will be placed in the Everything Else section of the main Questions page. **All your Metabase users can see questions in this section**, provided they have data access permission.
+
+### Archiving collections
+You can archive collections similarly to how you can archive questions. Click the archive icon in the top-right of the collection screen to archive it. This will also archive all questions in the collection, and importantly it will also remove all of those questions from all dashboards and Pulses that use those questions. So be careful!
+
+To restore a collection and its contents, click the `View Archive` icon in the top-right of the main Questions screen to see the archive, then hover over an item to reveal the `Unarchive` icon on the far right of the item. Questions within archived collections are not individually listed in the archive, so if you want to unarchive a specific question from an archived collection, you have to unarchive that whole collection.
+
+### What about labels?
+Older versions of Metabase provided labels as a way to organize and filter saved questions. If you were already using labels, you'll still be able to edit and use them for now from the Labels dropdown on lists of saved questions. However, **labels will be removed from Metabase in an upcoming version.** If your instance of Metabase was not using labels previously, you won't see the label tools at all anymore.
+
+What should you do if you want to prepare for the impending removal of labels? We recommend creating collections that match your most important labels, and moving the matching labeled questions into those collections.
+
+If you don't want to remove all the labels from your questions yet, we recommend at least ensuring that none of your questions have more than a single label. That way, if in the future we provide a migration tool that converts labels to collections automatically, there won't be any ambiguity with your labels.
+
+---
+
+## Next: custom segments and metrics
+Learn how to define custom segments and commonly referenced metrics in the [next section](07-segments-and-metrics.md).
diff --git a/docs/administration-guide/06-segments-and-metrics.md b/docs/administration-guide/07-segments-and-metrics.md
similarity index 99%
rename from docs/administration-guide/06-segments-and-metrics.md
rename to docs/administration-guide/07-segments-and-metrics.md
index 66fbd8892871f70c3fc460d0a87ce50966cfd360..312c5ed34d05696f431c7d6a56611fde9ad82490 100644
--- a/docs/administration-guide/06-segments-and-metrics.md
+++ b/docs/administration-guide/07-segments-and-metrics.md
@@ -47,4 +47,4 @@ Lastly, you can also view the revision history for each segment and metric from
 ---
 
 ## Next: configuring Metabase
-There are a few other settings you configure in Metabase. [Learn how](06-configuration-settings.md).
+There are a few other settings you configure in Metabase. [Learn how](08-configuration-settings.md).
diff --git a/docs/administration-guide/07-configuration-settings.md b/docs/administration-guide/08-configuration-settings.md
similarity index 94%
rename from docs/administration-guide/07-configuration-settings.md
rename to docs/administration-guide/08-configuration-settings.md
index e6fd1e81f02cea0fd720c2da401751bc39dfa09e..363b52dbf94a3cd3b39fd4f85ba08b24bd88083e 100644
--- a/docs/administration-guide/07-configuration-settings.md
+++ b/docs/administration-guide/08-configuration-settings.md
@@ -19,4 +19,4 @@ This option turns determines whether or not you allow anonymous data about your
 ---
 
 ## Next: Setting up Slack
-If you want to use Slack to enhance the Metabase experience then lets do that now. Let’s learn [how to setup Slack](07-setting-up-slack.md).
+If you want to use Slack to enhance the Metabase experience then lets do that now. Let’s learn [how to setup Slack](09-setting-up-slack.md).
diff --git a/docs/administration-guide/08-setting-up-slack.md b/docs/administration-guide/09-setting-up-slack.md
similarity index 96%
rename from docs/administration-guide/08-setting-up-slack.md
rename to docs/administration-guide/09-setting-up-slack.md
index 1825335a32c0be7db6b2702441f12c600ad29d0d..023a0e36436ab9e8a4552a975f6debc93c2573aa 100644
--- a/docs/administration-guide/08-setting-up-slack.md
+++ b/docs/administration-guide/09-setting-up-slack.md
@@ -30,4 +30,4 @@ That's it!  Metabase will automatically run a quick test to check that the API t
 ---
 
 ## Next: Single Sign-On
-Learn how to [configure Single Sign-On](08-single-sign-on.md) to let users sign in or sign up with just a click.
+Learn how to [configure Single Sign-On](10-single-sign-on.md) to let users sign in or sign up with just a click.
diff --git a/docs/administration-guide/09-single-sign-on.md b/docs/administration-guide/10-single-sign-on.md
similarity index 100%
rename from docs/administration-guide/09-single-sign-on.md
rename to docs/administration-guide/10-single-sign-on.md
diff --git a/docs/administration-guide/10-getting-started-guide.md b/docs/administration-guide/11-getting-started-guide.md
similarity index 100%
rename from docs/administration-guide/10-getting-started-guide.md
rename to docs/administration-guide/11-getting-started-guide.md
diff --git a/docs/administration-guide/databases/cratedb.md b/docs/administration-guide/databases/cratedb.md
new file mode 100644
index 0000000000000000000000000000000000000000..dcc6f5da89561b51876db3810b8a325069ad55d1
--- /dev/null
+++ b/docs/administration-guide/databases/cratedb.md
@@ -0,0 +1,18 @@
+
+## Working with CrateDB in Metabase
+
+Starting in v0.18.0 Metabase provides a driver for connecting to CrateDB directly and executing queries against any datasets you have. CrateDB uses the PostgreSQL Wire Protocol (since CrateDB v0.57), which makes it easy to use many PostgreSQL compatible tools and libraries directly with CrateDB. Therefore the CrateDB driver for Metabase provides and uses the PostgreSQL driver under the hood to connect to its data source. The below sections provide information on how to get connected to CrateDB.
+
+### Connecting to a CrateDB Dataset
+
+1. Make sure you have CrateDB [installed](https://crate.io/docs/reference/en/latest/installation.html), up and running.
+
+2. Setup a connection by providing a **Name** and a **Host**. CrateDB supports having a connection pool of multiple hosts. This can be achieved by providing a comma-separated list of multiple `<host>:<psql-port>` pairs.
+
+   ```
+   host1.example.com:5432,host2.example.com:5432
+   ```
+
+3. Click the `Save` button. Done.
+
+Metabase will now begin inspecting your CrateDB Dataset and finding any tables and fields to build up a sense for the schema. Give it a little bit of time to do its work and then you're all set to start querying.
\ No newline at end of file
diff --git a/docs/administration-guide/images/collections/collections-empty-state.png b/docs/administration-guide/images/collections/collections-empty-state.png
new file mode 100644
index 0000000000000000000000000000000000000000..6fb4d376bdd11cfb577c1d1749f05328a1f11384
Binary files /dev/null and b/docs/administration-guide/images/collections/collections-empty-state.png differ
diff --git a/docs/administration-guide/images/collections/permission-grid.png b/docs/administration-guide/images/collections/permission-grid.png
new file mode 100644
index 0000000000000000000000000000000000000000..f8190d72b12f4bf51429852b28ac61173b7de6e7
Binary files /dev/null and b/docs/administration-guide/images/collections/permission-grid.png differ
diff --git a/docs/administration-guide/start.md b/docs/administration-guide/start.md
index 65d7ead3352234888663730d919682b67cd050e4..64c2a375b34f2ec471c92ef6adb630369e346f3f 100644
--- a/docs/administration-guide/start.md
+++ b/docs/administration-guide/start.md
@@ -9,11 +9,12 @@ Are you in charge of managing Metabase for your organization? Then you're in the
 * [Editing your database metadata](03-metadata-editing.md)
 * [Managing user accounts](04-managing-users.md)
 * [Setting data permissions](05-setting-permissions.md)
-* [Creating segments and metrics](06-segments-and-metrics.md)
-* [Configuring settings](07-configuration-settings.md)
-* [Setting up Slack integration](08-setting-up-slack.md)
-* [Enabling single sign-on with Google](09-single-sign-on.md)
-* [Creating a Getting Started Guide for your team](10-getting-started-guide.md)
+* [Creating and managing collections](06-collections.md)
+* [Creating segments and metrics](07-segments-and-metrics.md)
+* [Configuring settings](08-configuration-settings.md)
+* [Setting up Slack integration](09-setting-up-slack.md)
+* [Enabling single sign-on with Google](10-single-sign-on.md)
+* [Creating a Getting Started Guide for your team](11-getting-started-guide.md)
 
 First things first, you'll need to install Metabase. If you haven’t done that yet, our [Installation Guide](../operations-guide/start.md#installing-and-running-metabase) will help you through the process.
 
diff --git a/docs/api-documentation.md b/docs/api-documentation.md
index 35ede378ede0d0e2fa501072341c65078287afa2..5957ea43df33565f6afd605da28add1390c464dd 100644
--- a/docs/api-documentation.md
+++ b/docs/api-documentation.md
@@ -1,4 +1,4 @@
-# API Documentation for Metabase v0.21.0-snapshot
+# API Documentation for Metabase v0.22.0-snapshot
 
 ## `GET /api/activity/`
 
@@ -34,7 +34,13 @@ Get all the `Cards`. Option filter param `f` can be used to change the set of Ca
    but other options include `mine`, `fav`, `database`, `table`, `recent`, `popular`, and `archived`. See corresponding implementation
    functions above for the specific behavior of each filter option. :card_index:
 
-   Optionally filter cards by LABEL slug.
+   Optionally filter cards by LABEL or COLLECTION slug. (COLLECTION can be a blank string, to signify cards with *no collection* should be returned.)
+
+   NOTES:
+
+   *  Filtering by LABEL is considered *deprecated*, as `Labels` will be removed from an upcoming version of Metabase in favor of `Collections`.
+   *  LABEL and COLLECTION params are mutually exclusive; if both are specified, LABEL will be ignored and Cards will only be filtered by their `Collection`.
+   *  If no `Collection` exists with the slug COLLECTION, this endpoint will return a 404.
 
 ##### PARAMS:
 
@@ -44,6 +50,8 @@ Get all the `Cards`. Option filter param `f` can be used to change the set of Ca
 
 *  **`label`** value may be nil, or if non-nil, value must be a non-blank string.
 
+*  **`collection`** value may be nil, or if non-nil, value must be a string.
+
 
 ## `GET /api/card/:id`
 
@@ -62,7 +70,7 @@ Create a new `Card`.
 
 *  **`dataset_query`** 
 
-*  **`description`** 
+*  **`description`** value may be nil, or if non-nil, value must be a non-blank string.
 
 *  **`display`** value must be a non-blank string.
 
@@ -70,6 +78,8 @@ Create a new `Card`.
 
 *  **`visualization_settings`** value must be a map.
 
+*  **`collection_id`** value may be nil, or if non-nil, value must be an integer greater than zero.
+
 
 ## `POST /api/card/:card-id/favorite`
 
@@ -83,6 +93,7 @@ Favorite a Card.
 ## `POST /api/card/:card-id/labels`
 
 Update the set of `Labels` that apply to a `Card`.
+   (This endpoint is considered DEPRECATED as Labels will be removed in a future version of Metabase.)
 
 ##### PARAMS:
 
@@ -104,13 +115,36 @@ Run the query associated with a Card.
 
 ## `POST /api/card/:card-id/query/csv`
 
-Run the query associated with a Card, and return its results as CSV.
+Run the query associated with a Card, and return its results as CSV. Note that this expects the parameters as serialized JSON in the 'parameters' parameter
 
 ##### PARAMS:
 
 *  **`card-id`** 
 
-*  **`parameters`** 
+*  **`parameters`** value may be nil, or if non-nil, value must be a valid JSON string.
+
+
+## `POST /api/card/:card-id/query/json`
+
+Run the query associated with a Card, and return its results as JSON. Note that this expects the parameters as serialized JSON in the 'parameters' parameter
+
+##### PARAMS:
+
+*  **`card-id`** 
+
+*  **`parameters`** value may be nil, or if non-nil, value must be a valid JSON string.
+
+
+## `POST /api/card/collections`
+
+Bulk update endpoint for Card Collections. Move a set of `Cards` with CARD_IDS into a `Collection` with COLLECTION_ID,
+   or remove them from any Collections by passing a `null` COLLECTION_ID.
+
+##### PARAMS:
+
+*  **`card_ids`** value must be an array. Each value must be an integer greater than zero.
+
+*  **`collection_id`** value may be nil, or if non-nil, value must be an integer greater than zero.
 
 
 ## `PUT /api/card/:id`
@@ -123,7 +157,7 @@ Update a `Card`.
 
 *  **`dataset_query`** 
 
-*  **`description`** 
+*  **`description`** value may be nil, or if non-nil, value must be a non-blank string.
 
 *  **`display`** value may be nil, or if non-nil, value must be a non-blank string.
 
@@ -133,6 +167,74 @@ Update a `Card`.
 
 *  **`archived`** value may be nil, or if non-nil, value must be a boolean.
 
+*  **`collection_id`** value may be nil, or if non-nil, value must be an integer greater than zero.
+
+
+## `GET /api/collection/`
+
+Fetch a list of all Collections that the current user has read permissions for.
+   This includes `:can_write`, which means whether the current user is allowed to add or remove Cards to this Collection; keep in mind
+   that regardless of this status you must be a superuser to modify properties of Collections themselves.
+
+   By default, this returns non-archived Collections, but instead you can show archived ones by passing `?archived=true`.
+
+##### PARAMS:
+
+*  **`archived`** value may be nil, or if non-nil, value must be a valid boolean (true or false).
+
+
+## `GET /api/collection/:id`
+
+Fetch a specific (non-archived) Collection, including cards that belong to it.
+
+##### PARAMS:
+
+*  **`id`** 
+
+
+## `GET /api/collection/graph`
+
+Fetch a graph of all Collection Permissions.
+
+
+## `POST /api/collection/`
+
+Create a new Collection.
+
+##### PARAMS:
+
+*  **`name`** value must be a non-blank string.
+
+*  **`color`** value must be a string that matches the regex `^#[0-9A-Fa-f]{6}$`.
+
+*  **`description`** value may be nil, or if non-nil, value must be a non-blank string.
+
+
+## `PUT /api/collection/:id`
+
+Modify an existing Collection, including archiving or unarchiving it.
+
+##### PARAMS:
+
+*  **`id`** 
+
+*  **`name`** value must be a non-blank string.
+
+*  **`color`** value must be a string that matches the regex `^#[0-9A-Fa-f]{6}$`.
+
+*  **`description`** value may be nil, or if non-nil, value must be a non-blank string.
+
+*  **`archived`** value may be nil, or if non-nil, value must be a boolean.
+
+
+## `PUT /api/collection/graph`
+
+Do a batch update of Collections Permissions by passing in a modified graph.
+
+##### PARAMS:
+
+*  **`body`** value must be a map.
+
 
 ## `DELETE /api/dashboard/:id`
 
@@ -425,6 +527,15 @@ Get historical query execution duration.
 *  **`query`** 
 
 
+## `POST /api/dataset/json`
+
+Execute a query and download the result data as a JSON file.
+
+##### PARAMS:
+
+*  **`query`** value must be a valid JSON string.
+
+
 ## `POST /api/email/test`
 
 Send a test email. You must be a superuser to do this.
@@ -522,7 +633,7 @@ Fetch basic info for the Getting Started guide.
 
 ## `DELETE /api/label/:id`
 
-Delete a `Label`. :label:
+[DEPRECATED] Delete a `Label`. :label:
 
 ##### PARAMS:
 
@@ -531,12 +642,12 @@ Delete a `Label`. :label:
 
 ## `GET /api/label/`
 
-List all `Labels`. :label:
+[DEPRECATED] List all `Labels`. :label:
 
 
 ## `POST /api/label/`
 
-Create a new `Label`. :label: 
+[DEPRECATED] Create a new `Label`. :label:
 
 ##### PARAMS:
 
@@ -547,7 +658,7 @@ Create a new `Label`. :label:
 
 ## `PUT /api/label/:id`
 
-Update a `Label`. :label:
+[DEPRECATED] Update a `Label`. :label:
 
 ##### PARAMS:
 
@@ -718,7 +829,7 @@ You must be a superuser to do this.
 
 ## `GET /api/permissions/group`
 
-Fetch all `PermissionsGroups`.
+Fetch all `PermissionsGroups`, including a count of the number of `:members` in that group.
 
 You must be a superuser to do this.
 
@@ -1381,6 +1492,14 @@ Logs.
 You must be a superuser to do this.
 
 
+## `GET /api/util/stats`
+
+Anonymous usage stats. Endpoint for testing, and eventually exposing this to instance admins to let them see
+  what is being phoned home.
+
+You must be a superuser to do this.
+
+
 ## `POST /api/util/password_check`
 
 Endpoint that checks if the supplied password meets the currently configured password complexity rules.
diff --git a/docs/contributing.md b/docs/contributing.md
index 7965b1434e09e7cd778ba3e077216bfe0b299733..fc7f8ea400090107f433a0b5eac9887cd143a622 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -66,7 +66,7 @@ Let your friends know about Metabase. Start a user group in your area. [Tweet ab
 
 ### Fix bugs
 
-By our definition, "Bugs" are situations where the program doesn't do what it was expected to according to the design or specification. These are typically scoped to issues where there is a clearly defined correct behaviour. It's usually safe to grab one of these, fix it, and submit a PR (with tests!). These will be merged without too much drama unless the PR touches a lot of code. Don't be offended if we ask you to make small modifications or add more tests. We're a bit OCD on code coverage and coding style.
+By our definition, "Bugs" are situations where the program doesn't do what it was expected to according to the design or specification. These are typically scoped to issues where there is a clearly defined correct behavior. It's usually safe to grab one of these, fix it, and submit a PR (with tests!). These will be merged without too much drama unless the PR touches a lot of code. Don't be offended if we ask you to make small modifications or add more tests. We're a bit OCD on code coverage and coding style.
 
 ### Help with Documentation
 
diff --git a/docs/developers-guide.md b/docs/developers-guide.md
index 712d0b40a7a9777b041a97e15fa091000795d4c0..727827f31f282e8e9c1e8c529c34c4b01b00f86b 100644
--- a/docs/developers-guide.md
+++ b/docs/developers-guide.md
@@ -53,11 +53,9 @@ Both components are built and assembled together into a single jar file which ru
 
 ### 3rd party dependencies
 
-Metabase depends on lots of other 3rd party libraries to run, so as you are developing you'll need to keep those up to date.  These don't run automatically during development, so kick them off manually when needed.
+Metabase depends on lots of other 3rd party libraries to run, so as you are developing you'll need to keep those up to date. Leiningen will automatically fetch Clojure dependencies when needed, but for JavaScript dependencies you'll need to kick off the installation process manually when needed.
 
 ```sh
-# clojure dependencies
-$ lein deps
 # javascript dependencies
 $ yarn
 ```
diff --git a/docs/faq.md b/docs/faq.md
index 8586e51817b6be6d3cdb5d01fd69301c7a95f62c..0af734c9342fdc2c7bc18e2bafc6916486acf3e2 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -39,7 +39,7 @@ Metabase currently supports:
 
 * Amazon Redshift
 * BigQuery
-* Crate (version 0.55 or higher)
+* CrateDB (version 0.57 or higher)
 * Druid
 * H2
 * MongoDB (version 3.0 or higher)
@@ -80,4 +80,4 @@ We are experimenting with offering paid support to a limited number of companies
 
 ### Can I embed charts or dashboards in another application?
 
-Not yet. We're working on it however, and you should expect it in the near future. (Late summer/early fall 2016). Keep tabs on it at the main [tracking issue](https://github.com/metabase/metabase/issues/1380)
\ No newline at end of file
+Not yet. We're working on it however, and you should expect it in the near future. (Late summer/early fall 2016). Keep tabs on it at the main [tracking issue](https://github.com/metabase/metabase/issues/1380)
diff --git a/docs/users-guide/03-asking-questions.md b/docs/users-guide/03-asking-questions.md
index dcf4fe2272788a5d29355b954aad51c14118bf95..26e61993e8268ba109d5b138ab6200c651f45e3a 100644
--- a/docs/users-guide/03-asking-questions.md
+++ b/docs/users-guide/03-asking-questions.md
@@ -49,7 +49,7 @@ If your Metabase admins have created special named filters, called segments, for
 
 ### Answer Output
 ---
-The last section of the question builder is where you select what you want the output of your answer to be, under the View dropdown. You’re basically telling Metabase, “I want to view the…” Metabase can output the answer to your question in three different ways:
+The last section of the question builder is where you select what you want the output of your answer to be, under the View dropdown. You’re basically telling Metabase, “I want to view the…” Metabase can output the answer to your question in four different ways:
 
 #### 1. Raw Data
 Raw Data is just a table with the answer listed in rows.  It's useful when you want to see the actual data you're working with, rather than a sum or average, etc., or when you're exploring a small table with a limited number of records.  
@@ -58,19 +58,29 @@ When you filter your data to see groups of interesting users, orders, etc., Raw
 
 #### 2. Basic Metrics
 
-What's a *metric*? It's a number that is derived from your source table and takes into consideration any filters you asked Metabase to apply to your question. So when you select one of these metrics, your answer will come back in the form of a number. The different basic metrics are:
+What's a *metric*? It's a number that is derived from your source table and takes into consideration any filters you asked Metabase to apply to your question. So when you select one of these metrics, your answer will come back in the form of a number. You can add additional metrics to your question using the `+` icon next to your selected metric.
 
-* **Count:** The total of number of rows in the answer. Each row corresponds to a separate record. If you want to know how many orders in the Orders table were placed with a price greater than $40, you’d filter by “Price greater than 40,” and then select Count, because you want Metabase to count how many orders matched your filter.
-* **Sum:** Sum of all the values in a column. This is really easy to get mixed up with Count — just remember that Count counts each *row*, but Sum adds up all the values in a single field. You’d use Sum to get your total revenue dollar amount, for example.
-* **Average:** Average of all the values in a column.
-* **Number of Distinct Values:** Number of unique values in all the cells of a single column. This would be useful to find out things like how many different *types* of products were sold last month (not how many were sold in total).
-* **Cumulative Sum:** This gives you a running total of a specific column. This will look exactly the same as Sum unless you break out your answer by day, week, month, etc. (See the next section about breaking out metrics.) An example would be total revenue over time.
-* **Standard Deviation:** A number which expresses how much the values of a column vary, plus or minus, from the average of that column.
+The different basic metrics are:
+
+* **Count of rows:** The total of number of rows in the answer. Each row corresponds to a separate record. If you want to know how many orders in the Orders table were placed with a price greater than $40, you’d filter by “Price greater than 40,” and then select Count, because you want Metabase to count how many orders matched your filter.
+* **Sum of …:** Sum of all the values in a column. This is really easy to get mixed up with Count — just remember that Count counts each *row*, but Sum adds up all the values in a single field. You’d use Sum to get your total revenue dollar amount, for example.
+* **Average of …:** Average of all the values in a column.
+* **Number of distinct values of…:** Number of unique values in all the cells of a single column. This would be useful to find out things like how many different *types* of products were sold last month (not how many were sold in total).
+* **Cumulative sum of…:** This gives you a running total of a specific column. This will look exactly the same as Sum unless you break out your answer by day, week, month, etc. (See the next section about breaking out metrics.) An example would be total revenue over time.
+* **Cumulative count of rows:** This gives you a running total of the number of rows in the table over time. Just like `Cumulative sum of…`, this will look exactly the same as `Count of rows` unless you break out your answer a time field.
+* **Standard deviation of …:** A number which expresses how much the values of a column vary, plus or minus, from the average of that column.
+* **Minimum of …:** The minimum value present in the selected field.
+* **Maximum of …:** The maximum value present in the selected field.
 
 #### 3. Common Metrics
 
 If your admins have created any named metrics that are specific to your company or organization, they will be in this dropdown under the **Common Metrics** section. These might be things like your company’s official way of calculating revenue.
 
+#### 4. Custom Expressions
+Custom expressions allow you to do simple arithmetic within or between aggregation functions. For example, you could do `Average(FieldX) + Sum(FieldY)` or `Max(FieldX - FieldY)`, where `FieldX` and `FieldY` are fields in the currently selected table. You can either use your cursor to select suggested functions and fields, or simply start typing and use the autocomplete. If you are a Metabase administrator, you can now also use custom aggregation expressions when creating defined common metrics in the Admin Panel.
+
+Currently, you can use any of the basic aggregation functions listed in #2 above in your custom expression, and these basic mathematical operators: `+`, `-`, `*` (multiply), `/` (divide). You can also use parentheses to clarify the order of operations.
+
 ### Breaking Out Metrics: Add a group
 ---
 Metrics are great by themselves if the answer you’re looking for is just a simple, single number. But often you'll want to know more detailed information than that.
@@ -100,7 +110,7 @@ A custom field is helpful if you need to create a new field based on a calculati
 
 ![Custom fields](images/custom-fields/blank-formula.png)
 
-Say we had a table of individual baseball games, and we wanted to figure out how many more runs the home team scored than the away team (the “run differential”). If we have one field with the home team’s score, and another field with the away team’s score, we could type a formula like this:
+Say we had a table of baseball games, each row representing a single game, and we wanted to figure out how many more runs the home team scored than the away team (the “run differential”). If we have one field with the home team’s score, and another field with the away team’s score, we could type a formula like this:
 
 ![Formula](images/custom-fields/filled-formula.png)
 
diff --git a/docs/users-guide/04-visualizing-results.md b/docs/users-guide/04-visualizing-results.md
index 179b483a1951d1e929a45ff5b64fc70152b48c9a..0bce52900661543e6af9e8b237b3b3daee5266d8 100644
--- a/docs/users-guide/04-visualizing-results.md
+++ b/docs/users-guide/04-visualizing-results.md
@@ -5,49 +5,67 @@ While tables are useful for looking up information or finding specific numbers,
 In Metabase, an answer to a question can be visualized in a number of ways:
 
 * Number
+* Progress bar
 * Table
-* Line
-* Bar
-* Pie
-* Area
+* Line chart
+* Bar chart
+* Area chart
+* Scatterplot or bubble chart
+* Pie/donut chart
 * Map
 
 To change how the answer to your question is displayed, click on the Visualization dropdown menu beneath the question builder bar.
 
 ![visualizechoices](images/VisualizeChoices.png)
 
-If a particular visualization doesn’t really make sense for your answer, the format option will appear faded in the dropdown menu.
+If a particular visualization doesn’t really make sense for your answer, the format option will appear grayed-out in the dropdown menu. You can still select a grayed-out option, though you might need to click on the chart options gear icon to make your selection work with your data.
 
-Once a question is answered, you can save or download the answer, or add it to a dashboard.
+Once a question is answered, you can save or download the answer, or add it to a dashboard or Pulse.
 
-### Visualization options
+### Visualization types and options
 
 Each visualization type has its own advanced options you can tweak. Just click the gear icon next to the visualization selector. Here's an overview of what you can do:
 
 #### Numbers
-The options for numbers include adding prefixes or suffixes to your number (so you can do things like put a currency symbol in front or a percent at the end), setting the number of decimal places you want to include, and multiplying your result by a number (like if you want to multiply a decimal by 100 to make it look like a percent).
+This option is for displaying a single number, nice and big. The options for numbers include adding character prefixes or suffixes to it (so you can do things like put a currency symbol in front or a percent at the end), setting the number of decimal places you want to include, and multiplying your result by a number (like if you want to multiply a decimal by 100 to make it look like a percent).
+
+#### Progress bars
+Progress bars are for comparing a single number result to a goal value that you input. Open up the chart options for your progress bar to choose a goal for it, and Metabase will show you how far away your question's current result is from the goal.
 
 #### Tables
-The table options allow you to hide and rearrange fields in the table you're looking at.
+The Table option is good for looking at tabular data (duh), or for lists of things like users. The options allow you to hide and rearrange fields in the table you're looking at.
 
 #### Line, bar, and area charts
+Line charts are best for displaying the trend of a number over time, especially when you have lots of x-axis values. Bar charts are great for displaying a metric grouped by a category (e.g., the number of users you have by country), and they can also be useful for showing a number over time if you have a smaller number of x-axis values (like orders per month this year). Area charts are useful when comparing the the proportions between two metrics over time. Both bar and area charts can be stacked.
+
 These three charting types have very similar options, which are broken up into the following:
-* **Data** — choose the fields you want to plot on your x and y axes. This also allows you to plot fields from unaggregated tables.
-* **Display** — here's where you can make some cosmetic changes, like setting colors, and stacking bar or area charts.
-* **Axes** — this is where you can hide axis markers or change their ranges.
+* **Data** — choose the fields you want to plot on your x and y axes. This is mostly useful if your table or result set contains more than two columns, like if you're trying to graph fields from an unaggregated table. You can also add additional metric fields by clicking the `Add another series` link below the y-axis dropdown, or break your current metric out by an additional dimension by clicking the `Add a series breakout` link below the x-axis dropdown (note that you can't add an additional series breakout if you have more than one metric/series).
+* **Display** — here's where you can make some cosmetic changes, like setting colors, and stacking bar or area charts. With line and area charts, you can also change the line style (line, curve, or step). We've also recently added the ability to create a goal line for your chart, and to configure how your chart deals with x-axis points that have missing y-axis values.
+* **Axes** — this is where you can hide axis markers or change their ranges, and turn split axes on or off. You can also configure the way your axes are scaled, if you're into that kind of thing.
 * **Labels** — if you want to hide axis labels or customize them, here's where to go.
 
-#### Pie charts
-The options for pie charts let you choose which field to use as your measurement, and which one to use for the pie slices. You can also customize the pie chart's legend.
+#### Scatterplots and bubble charts
+Scatterplots are useful for visualizing the correlation between two variables, like comparing the age of your users vs. how many dollars they've spent on your products. To use a scatterplot, you'll need to ask a question that results in two numeric columns, like `Count of Orders grouped by Customer Age`. Alternatively, you can use a raw data table and select the two numeric fields you want to use in the chart options.
+
+If you have a third numeric field, you can also create a bubble chart. Select the Scatter visualization, then open up the chart options and select a field in the bubble size dropdown. This field will be used to determine the size of each bubble on your chart. For example, you could use a field that contains the number or count of items for the given x-y pair — i.e., larger bubbles for larger total dollar amounts spent on orders.
+
+Scatterplots and bubble charts also have similar chart options as line, bar, and area charts.
+
+#### Pie or donut charts
+A pie or donut chart can be used when breaking out a metric by a single dimension, especially when the number of possible breakouts is small, like users by gender. If you have more than a few breakouts, like users by country, it's usually better to use a bar chart so that your users can more easily compare the relative sizes of each bar.
+
+The options for pie charts let you choose which field to use as your measurement, and which one to use for the dimension (i.e., the pie slices). You can also customize the pie chart's legend, whether or not to show each slice's percent of the whole in the legend, and the minimum size a slice needs to be in order for it to be displayed.
 
 #### Maps
-When you select the Map visualization setting, Metabase will automatically try and pick the best kind of map to use based on the table or result you're currently looking at. Here are the maps that Metabase uses:
+When you select the Map visualization setting, Metabase will automatically try and pick the best kind of map to use based on the table or result set you're currently looking at. Here are the maps that Metabase uses:
+
+* **United States Map** — Creating a map of the United States from your data requires your results to contain a column field with states. This lets you do things like visualize the count of your users broken out by state, with darker states representing more users.
+* **Country Map** — To visualize your results in the format of a map of the world broken out by country, your result must contain a field with countries. (E.g., count of users by country.)
+* **Pin Map** — If your table contains a latitude and longitude field, Metabase will try to display it as a pin map of the world. This will put one pin on the map for each row in your table, based on the latitude and longitude fields. You can try this with the Sample Dataset that's included in Metabase: start a new question and select the People table, use `raw data` for your view, and choose the Map option for your visualization. you'll see a map of the world, with each dot representing the latitude and longitude coordinates of a single person from the People table.
 
-* **United States Map** — Creating a map of the United States from your data requires your results to contain a column field with states. This lets you do things like visualize the count of your users broken out by state.
-* **Country Map** — To visualize your results in the format of a map of the world broken out by country, your result must contain a field with countries.
-* **Pin Map** — If your table contains a latitude and longitude field, Metabase will try to display it as a pin map of the world. This will put one pin on the map for each row in your table, based on the latitude and longitude fields.
+When you open up the Map options, you can manually switch between a region map (i.e., United States or world) and a pin map. If you're using a region map, you can also choose which field to use as the measurement, and which to use as the region (i.e. State or Country).
 
-When you open up the Map options, you can manually switch between a region map (i.e., United States or world) and a pin map. (And don't worry — a flexible way to add custom maps of other countries and regions will be coming soon.) If you're using a region map, you can also choose which field to use as the measurement, and which to use as the region (i.e. State or Country).
+Metabase now also allows administrators to add custom region maps via GeoJSON files through the Metabase Admin Panel.
 
 ---
 
diff --git a/docs/users-guide/05-sharing-answers.md b/docs/users-guide/05-sharing-answers.md
index 5f495b35838da99d2b56012cbb067601215b0ac5..7c478b59d081d6bf3a863920676f326a86416b39 100644
--- a/docs/users-guide/05-sharing-answers.md
+++ b/docs/users-guide/05-sharing-answers.md
@@ -9,38 +9,39 @@ Whenever you’ve arrived at an answer that you want to save for later, click th
 
 A pop-up box will appear, you to give your question a name and a description. We suggest phrasing the names for your questions in the form of a question, such as, “How many customers did we have last month?” After saving your question, you'll be asked if you want to add it to a dashboard.
 
-Now, whenever you want to refer to your question again, you can find it in the saved questions list by clicking on the **Questions** link from the main navigation. To edit your question, go to it and click the pencil icon in the top-right.
+Now, whenever you want to refer to your question again you can find it in the saved questions list by clicking on the **Questions** link from the main navigation. To edit your question, go to it and click the pencil icon in the top-right.
 
 ### Organizing and Finding your Saved Questions
-
-
 After your team has been using Metabase for a while, you’ll probably end up with lots of saved questions. The Questions page has several tools that’ll help you organize things and find what you’re looking for.
 
 ![Questions](images/saved-questions.png)
 
-#### Shortcuts
-In the top left, you’ll find shortcuts to your favorite questions (mark a question as a favorite by clicking on the star icon that appears on the far right when you hover over it), questions you’ve recently viewed, questions that you’ve saved personally, and popular questions that are used the most by your team.
-
-#### Search
-You can search for a question by typing keywords into the **Search for a question** area.
+#### Collections
+Administrators of Metabase can create collections to put saved questions in. Depending on the permissions you've been given to collections, you'll be able to view the questions inside, edit them, and move questions from one collection to another. Questions that aren't saved in any collection will appear in the "Everything else" section of the main Questions page, and are visible to all Metabase users in your organization. If you're an administrator of your Metabase instance, here are [instructions for creating collections and managing permissions](../administration-guide/06-collections.md).
 
 #### Labels
-One great way to organize your questions is to label them. Create and organize your team’s labels by clicking on the **Labels** heading in the left menu on the Questions page. To create a label, just choose a color (or emoji!) for it, give it a name, and click **Create Label**. You can edit or delete a label by hovering of a label and clicking the **Edit** or **X** icon. You can create labels that pertain to different teams, projects, products, reports, dashboards, or whatever you want!
+Older versions of Metabase included a way to add labels to your questions, but this feature will be going away in a future version of Metabase. Currently, if your team was already using labels, you'll still be able to edit and apply them to questions. Here are some [suggestions for switching from labels to collections](../administration-guide/06-collections.md#what-about-labels).
 
-You can assign as many labels as you want to your questions. Just hover over a question in your list and click the label icon, then pick the label(s) you want to apply to the question.
+#### Shortcuts
+At the top of lists of saved questions you’ll find a dropdown with shortcuts to your favorite questions (mark a question as a favorite by clicking on the star icon that appears when you hover over it), questions you’ve recently viewed, questions that you’ve saved personally, and popular questions that are used the most by your team.
+
+#### Search and filtering
+On the main Questions page, you can search through all of your collections for a particular question using the search box in the top-right. You can also filter lists of saved questions by typing in the `Filter the list…` area.
 
-![Actions](images/question-actions.png)
+#### Moving
+To move a question into a collection, or from one collection to another, hover over it and click on the right-arrow icon that appears on the far right of the question. Note that you have to have permission to edit the collection that you're moving a question into, and the collection you're moving the question out of.
 
-If you have several questions that you want to give the same label to, just click the checkbox on the left side of each question’s title when you hover over it to select your questions, then click the Labels dropdown at the top of the page.
+#### Archiving
+Sometimes questions outlive their usefulness and need to be sent to Question Heaven. To archive a question, just click on the box icon that appears on the far right when you hover over a question. Collections can also be archived and unarchived, but only by Metabase administrators.
 
-![Checkbox](images/question-checkbox.png)
+Note that archiving a question removes it from all dashboards or Pulses where it appears, so be careful!
 
-Once you’ve created a label, it’ll appear in the list on the left side of the screen. Clicking on a label, either in the list of labels or on an individual question, will show you all the questions that have that label.
+If you have second thoughts and want to bring an archived question back, you can see all your archived questions from the **Archive** icon at the top-right of the Questions page. To unarchive a question, hover over it and click the box icon that appears on the far right.
 
-#### Archiving
-Sometimes questions outlive their usefulness and need to be sent to Question Heaven. To archive a question, just click on the box icon that appears on the far right when you hover over a question. You can also archive multiple questions the same way you apply labels to multiple questions.
+#### Selecting multiple questions
+Clicking on the icon to the left of questions let's you select several at once so that you can move or archive many questions at once.
 
-If you have second thoughts and want to bring an archived question back, you can see all your archived questions from the **Archive** link at the bottom of the left menu. To unarchive a question, hover over it and click the box icon that appears on the far right.
+![Selecting questions](images/question-checkbox.png)
 
 ---
 
diff --git a/docs/users-guide/07-dashboard-filters.md b/docs/users-guide/07-dashboard-filters.md
index d4965e39940f975e5c802bdf14da7693aadf891b..fde813da6856b156452c5daf647ebd9c2ec1a7ef 100644
--- a/docs/users-guide/07-dashboard-filters.md
+++ b/docs/users-guide/07-dashboard-filters.md
@@ -12,7 +12,13 @@ To add a filter to a dashboard, first enter dashboard editing mode, then click t
 
 ![Add a Filter](images/dashboard-filters/01-add-filter.png)
 
-You can choose from a number of filter types: time, location, ID, or other categories. The type of filter you choose will determine what the filter widget will look like, and will also determine what fields you’ll be able to filter your cards by. Let’s try a time filter, and then select the Month and Year option.
+You can choose from a number of filter types: Time, Location, ID, or Other Categories. The type of filter you choose will determine what the filter widget will look like, and will also determine what fields you’ll be able to filter your cards by:
+* **Time:** when picking a Time filter, you'll also be prompted to pick a specific type of filter widget: Month and Year, Quarter and Year, Single Date, Date Range, or Relative Date. Single Date and Date Range will provide a calendar widget, while the other options all provide slightly different dropdown interfaces for picking values.
+* **Location:** there are four types of Location filters to choose from: City, State, ZIP or Postal Code, and Country. These will all show up as input box widgets unless the field(s) you're filtering contain fewer than 40 distinct possible values, in which case the widget will be a dropdown.
+* **ID:** this filter provides a simple input box where you can type the ID of a user, order, etc.
+* **Other Categories:** this is a flexible filter type that will let you create either a dropdown or input box to filter on any category field in your cards. Whether the filter widget is displayed as a dropdown or an input box is dependent on the field(s) you pick to filter on: if there are fewer than 40 distinct possible values for that field, you'll see a dropdown; otherwise you'll see an input box. (A future version of Metabase will include type-ahead search suggestions for the input box widget.)
+
+For our example, we'll select a Time filter, and then select the Month and Year option.
 
 ![Choose filter type](images/dashboard-filters/02-filter-type.png)
 
@@ -22,6 +28,10 @@ Now we’ve entered a new mode where we’ll need to wire up each card on our da
 
 So here’s what we’re doing — when we pick a month and year with our new filter, the filter needs to know which field in the card to filter on. For example, if we have a `Total Orders` card, and each order has a `Date Ordered` as well as a `Date Delivered`, we have to pick which of those fields to filter — do we want to see all the orders *placed* in January, or do we want to see all the orders *delivered* in January? So, for each card on our dashboard, we’ll pick a date field to connect to the filter. If one of your cards says there aren’t any valid fields, that just means that card doesn’t contain any fields that match the kind of filter you chose.
 
+#### Filtering SQL-based cards
+Note that if your dashboard includes cards that were created using the SQL/native query editor, you'll need to add a bit of additional markup to the SQL in those cards in order to use a dashboard filter on them. [Using SQL parameters](12-sql-parameters.md)
+
+
 ![Select fields](images/dashboard-filters/04-select-fields.png)
 
 Before we click the `Done` button at the top of the screen, we can also customize the label of our new filter by clicking on the pencil icon next to it. We’ll type in a new label and hit enter. Now we’ll click `Done`, and then save the changes to our dashboard with the `Save` button.
@@ -57,9 +67,9 @@ Here are a few tips to get the most out of dashboard filters:
 ### Some things to keep in mind
 
 - When you activate a dashboard filter, any card that isn’t wired up to the filter will fade out to indicate it’s not being filtered. If you activate more than one filter at the same time, cards will fade out unless they’re wired up to *every* active filter.
-- If you have a card with multiple series on it that you want to use with a dashboard filter, then just make sure to select a filtering field for each of the series in the card.
-- While connecting cards to a filter, you might see a warning message that says, `The values in this field don’t overlap with the values of any other fields you’ve chosen`. For example, maybe you selected the `Type of Pants` field for one card, but the `Types of Boats` field for another card; if you’re using those fields for the same filter, this is problematic because the filter would then give options to the user that don’t work for both cards.
-- You can’t use a dashboard filter with a field in a question if that field is already being used in the definition of the question. For example, say you have a question called `Orders in January`, which counts all the orders and has a filter on the `Date Order Was Placed` field to only select the orders placed January — you can’t then connect a dashboard filter to the `Orders in January` card through the `Date Order Was Placed` field, because that field is already being used to filter the underlying question.
+- If you have a card with multiple series on it that you want to use with a dashboard filter, then just make sure to select a field to be filtered for each of the series in the card.
+- While connecting cards to a filter, you might see a warning message that says, `The values in this field don’t overlap with the values of any other fields you’ve chosen`. For example, maybe you selected the `Type of Pants` field for one card, but the `Types of Boats` field for another card; if you’re using those fields for the same filter, this is problematic because the filter would then give options to the user that wouldn't work for both cards (like, `Chinos, Jeans, Kayak, Slacks, Yacht`). Metabase prefers to prevent such silliness.
+- You can’t use a dashboard filter with a field in a question if that field is already being used in the definition of the question. For example, say you have a question called `Orders in January`, which counts all the orders and has a filter on the `Date Order Was Placed` field to only select the orders placed January — you can’t then connect a dashboard filter to the `Orders in January` card through the `Date Order Was Placed` field, because that field is already being used to filter the underlying question's data.
 
 ---
 
diff --git a/docs/users-guide/images/SaveButton.png b/docs/users-guide/images/SaveButton.png
index f21509de2ae6f3c332fb435550aa883924a1ce3b..1dd0ec501411e9db30407f98318eb2471844d15c 100644
Binary files a/docs/users-guide/images/SaveButton.png and b/docs/users-guide/images/SaveButton.png differ
diff --git a/docs/users-guide/images/question-actions.png b/docs/users-guide/images/question-actions.png
deleted file mode 100644
index 16611283d9fe01bca0d67f168a5524bb5324fc99..0000000000000000000000000000000000000000
Binary files a/docs/users-guide/images/question-actions.png and /dev/null differ
diff --git a/docs/users-guide/images/saved-questions.png b/docs/users-guide/images/saved-questions.png
index ed1511230e67f2910bba69ee19149077cb0c76ed..91c771735e8881859e4c34bb7242c96f667f73c3 100644
Binary files a/docs/users-guide/images/saved-questions.png and b/docs/users-guide/images/saved-questions.png differ
diff --git a/docs/users-guide/start.md b/docs/users-guide/start.md
index 4422bd2167093500645adacafdfee4a64e2a378e..164823359033c98ff04f6400a59305eec12ab761 100644
--- a/docs/users-guide/start.md
+++ b/docs/users-guide/start.md
@@ -4,7 +4,7 @@
 
 *   [What Metabase does](01-what-is-metabase.md)
 *   [The basics of database terminology](02-database-basics.md)
-*   [What Metabase questions are made up of](03-asking-questions.md)
+*   [Asking questions in Metabase](03-asking-questions.md)
 *   [How to visualize the answers to questions](04-visualizing-results.md)
 *   [Sharing and organizing your saved questions](05-sharing-answers.md)
 *   [Creating dashboards](06-dashboards.md)
diff --git a/frontend/interfaces/underscore.js b/frontend/interfaces/underscore.js
index 2093902740da8bae5394d7458a6b9d4bfb3c26c7..c6c3210fe8c8aad3c78f660e60759441d95b318d 100644
--- a/frontend/interfaces/underscore.js
+++ b/frontend/interfaces/underscore.js
@@ -15,7 +15,6 @@ declare module "underscore" {
 
   declare function flatten<S>(a: Array<Array<S>>): S[];
 
-  declare function any<T>(list: Array<T>, pred: (el: T)=>boolean): boolean;
 
   declare function each<T>(o: {[key:string]: T}, iteratee: (val: T, key: string)=>void): void;
   declare function each<T>(a: T[], iteratee: (val: T, key: string)=>void): void;
@@ -27,6 +26,9 @@ declare module "underscore" {
 
   declare function every<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
   declare function some<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
+  declare function all<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
+  declare function any<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
+  declare function contains<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
 
   declare function initial<T>(a: Array<T>, n?: number): Array<T>;
   declare function rest<T>(a: Array<T>, index?: number): Array<T>;
diff --git a/frontend/src/metabase/App.jsx b/frontend/src/metabase/App.jsx
index d80a693f399ca7341dfa214aa703600d1cdaa253..3c28474c21b0e2599b8232d68c7484da8e1348ea 100644
--- a/frontend/src/metabase/App.jsx
+++ b/frontend/src/metabase/App.jsx
@@ -2,6 +2,8 @@ import React, { Component, PropTypes } from "react";
 
 import Navbar from "metabase/nav/containers/Navbar.jsx";
 
+import UndoListing from "metabase/containers/UndoListing";
+
 export default class App extends Component {
     render() {
         const { children, location } = this.props;
@@ -9,8 +11,8 @@ export default class App extends Component {
             <div className="spread flex flex-column">
                 <Navbar location={location} className="flex-no-shrink" />
                 {children}
+                <UndoListing />
             </div>
         )
     }
 }
-
diff --git a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx
index 78a3ff6f4f10747469cade90bd673fcba62b2b3c..474efc201e39f24594f3d7c3d76f833ba3284fde 100644
--- a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx
+++ b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx
@@ -15,7 +15,7 @@ export default class CreatedDatabaseModal extends Component {
         return (
             <ModalContent
                 title="Your database has been added!"
-                closeFn={onClose}
+                onClose={onClose}
             >
                 <div className="Form-inputs mb4">
                     <p>
diff --git a/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx b/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx
index fe724b724944505a9f48df6cffe8d760a4a16fbb..920c65e77fabeef9271b229c54644441f55002f4 100644
--- a/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx
+++ b/frontend/src/metabase/admin/databases/components/DeleteDatabaseModal.jsx
@@ -51,7 +51,7 @@ export default class DeleteDatabaseModal extends Component {
         return (
             <ModalContent
                 title="Delete Database"
-                closeFn={this.props.onClose}
+                onClose={this.props.onClose}
             >
                 <div className="Form-inputs mb4">
                     { database.is_sample &&
diff --git a/frontend/src/metabase/admin/datamodel/components/FormInput.jsx b/frontend/src/metabase/admin/datamodel/components/FormInput.jsx
index 9058e9dbc666724dc004a1b9b8a8088ad649cb71..0cb6812714174fec17835e381f7893e0079f50a6 100644
--- a/frontend/src/metabase/admin/datamodel/components/FormInput.jsx
+++ b/frontend/src/metabase/admin/datamodel/components/FormInput.jsx
@@ -12,7 +12,7 @@ export default class FormInput extends Component {
             <input
                 type="text"
                 placeholder={placeholder}
-                className={cx("input full text-default h4", { "border-error": !field.active && field.visited && field.invalid }, className)}
+                className={cx("input full", { "border-error": !field.active && field.visited && field.invalid }, className)}
                 {...formDomOnlyProps(field)}
             />
         );
diff --git a/frontend/src/metabase/admin/datamodel/components/FormTextArea.jsx b/frontend/src/metabase/admin/datamodel/components/FormTextArea.jsx
index c692ee948896302aba07e536de4c71b074839715..e440768bc3e53ce118678d438daba3ba15b9d6b2 100644
--- a/frontend/src/metabase/admin/datamodel/components/FormTextArea.jsx
+++ b/frontend/src/metabase/admin/datamodel/components/FormTextArea.jsx
@@ -12,7 +12,7 @@ export default class FormTextArea extends Component {
         return (
             <textarea
                 placeholder={placeholder}
-                className={cx("input full text-default h4", { "border-error": !field.active && field.visited && field.invalid }, className)}
+                className={cx("input full", { "border-error": !field.active && field.visited && field.invalid }, className)}
                 {...formDomOnlyProps(field)}
             />
         );
diff --git a/frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx b/frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx
index 06ac5d4348f9045770d9677c52274176753314e4..4393e9d0b1621ed908e63510ceebb3926c89cc85 100644
--- a/frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx
+++ b/frontend/src/metabase/admin/datamodel/components/ObjectRetireModal.jsx
@@ -32,7 +32,7 @@ export default class ObjectRetireModal extends Component {
         return (
             <ModalContent
                 title={"Retire This " + capitalize(objectType)}
-                closeFn={this.props.onClose}
+                onClose={this.props.onClose}
             >
                 <form className="flex flex-column flex-full">
                     <div className="Form-inputs pb4">
@@ -40,7 +40,7 @@ export default class ObjectRetireModal extends Component {
                         <p>If you're sure you want to retire this {objectType}, please write a quick explanation of why it's being retired:</p>
                         <textarea
                             ref="revision_message"
-                            className="input full text-default h4"
+                            className="input full"
                             placeholder={"This will show up in the activity feed and in an email that will be sent to anyone on your team who created something that uses this " + objectType + "."}
                             onChange={(e) => this.setState({ valid: !!e.target.value })}
                         />
diff --git a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx
index daa7e5f96e711a12641f1404f98c2c8a52408b9f..f3ffc2c8e365b2c4d2ee8fc685c3989a19ec1487 100644
--- a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx
+++ b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx
@@ -13,11 +13,13 @@ import { formatValue } from "metabase/lib/formatting";
 import { metricFormSelectors } from "../selectors";
 import { reduxForm } from "redux-form";
 
+import Query from "metabase/lib/query";
+
 import cx from "classnames";
 
 @reduxForm({
     form: "metric",
-    fields: ["id", "name", "description", "table_id", "definition", "revision_message"],
+    fields: ["id", "name", "description", "table_id", "definition", "revision_message", "show_in_getting_started"],
     validate: (values) => {
         const errors = {};
         if (!values.name) {
@@ -31,8 +33,9 @@ import cx from "classnames";
                 errors.revision_message = "Revision message is required";
             }
         }
-        if (!values.definition || !values.definition.filter || !values.definition.aggregation || values.definition.aggregation[0] == null) {
-            errors.definition = "Aggreagtion is required";
+        let aggregations = values.definition && Query.getAggregations(values.definition);
+        if (!aggregations || aggregations.length === 0) {
+            errors.definition = "Aggregation is required";
         }
         return errors;
     }
diff --git a/frontend/src/metabase/admin/datamodel/selectors.js b/frontend/src/metabase/admin/datamodel/selectors.js
index 8feb9612b0469de7a734e279e28a9a25cab63911..2d957762ac9adfe9ce8289576d1bbf41f2d72800 100644
--- a/frontend/src/metabase/admin/datamodel/selectors.js
+++ b/frontend/src/metabase/admin/datamodel/selectors.js
@@ -45,7 +45,7 @@ export const metricEditSelectors = createSelector(
     tableMetadataSelector,
     (metrics, id, tableId, tableMetadata) => ({
         metric: id == null ?
-            { id: null, table_id: tableId, definition: { aggregation: [null], filter: [] } } :
+            { id: null, table_id: tableId, definition: { aggregation: [null] } } :
             metrics[id],
         tableMetadata
     })
diff --git a/frontend/src/metabase/admin/people/components/EditUserForm.jsx b/frontend/src/metabase/admin/people/components/EditUserForm.jsx
index d9a05e1f2a839d85533f1f6f1f0d5529419be990..a5177ffa9f31b71834d33104ccdd0555ba407bef 100644
--- a/frontend/src/metabase/admin/people/components/EditUserForm.jsx
+++ b/frontend/src/metabase/admin/people/components/EditUserForm.jsx
@@ -11,9 +11,10 @@ import MetabaseUtils from "metabase/lib/utils";
 import SelectButton from "metabase/components/SelectButton.jsx";
 import Toggle from "metabase/components/Toggle.jsx";
 import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx";
+import Button from "metabase/components/Button.jsx";
+import { ModalFooter } from "metabase/components/ModalContent.jsx";
 
 import _ from "underscore";
-import cx from "classnames";
 
 import { isAdminGroup, canEditMembership } from "metabase/lib/groups";
 
@@ -149,13 +150,14 @@ export default class EditUserForm extends Component {
                     : null }
                 </div>
 
-                <div className="Form-actions">
-                    <button className={cx("Button", {"Button--primary": valid})} disabled={!valid}>
+                <ModalFooter>
+                    <Button type="button" onClick={this.cancel.bind(this)}>
+                        Cancel
+                    </Button>
+                    <Button primary disabled={!valid}>
                         { buttonText ? buttonText : "Save Changes" }
-                    </button>
-                    <span className="mx1">or</span>
-                    <a className="link text-bold" onClick={this.cancel.bind(this)}>Cancel</a>
-                </div>
+                    </Button>
+                </ModalFooter>
             </form>
         );
     }
diff --git a/frontend/src/metabase/admin/people/components/GroupsListing.jsx b/frontend/src/metabase/admin/people/components/GroupsListing.jsx
index fd745e1172a74c0ba82636f6209504717ea00ff9..3e037e14736d9c92d05f55ab8aeda4ed2f658bb0 100644
--- a/frontend/src/metabase/admin/people/components/GroupsListing.jsx
+++ b/frontend/src/metabase/admin/people/components/GroupsListing.jsx
@@ -52,7 +52,7 @@ function AddGroupRow({ text, onCancelClicked, onCreateClicked, onTextChange }) {
 
 function DeleteGroupModal({ group, onConfirm = () => {} , onClose = () => {} }) {
     return (
-        <ModalContent title="Remove this group?" closeFn={onClose}>
+        <ModalContent title="Remove this group?" onClose={onClose}>
             <p className="px4 pb4">
                 Are you sure? All members of this group will lose any permissions settings the have based on this group.
                 This can't be undone.
diff --git a/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx b/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx
index 53bbae36f9eb6790c5c592dccd272c1ea4d0fe62..c306b66c19fb0e0f3bea3f54a890a94e2199f618 100644
--- a/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx
+++ b/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx
@@ -9,11 +9,11 @@ import AdminPaneLayout from "metabase/components/AdminPaneLayout.jsx";
 import MetabaseSettings from "metabase/lib/settings";
 import MetabaseUtils from "metabase/lib/utils";
 import Modal from "metabase/components/Modal.jsx";
-import ModalContent from "metabase/components/ModalContent.jsx";
 import PasswordReveal from "metabase/components/PasswordReveal.jsx";
 import UserAvatar from "metabase/components/UserAvatar.jsx";
 import Icon from "metabase/components/Icon.jsx";
 import Tooltip from "metabase/components/Tooltip.jsx";
+import Button from "metabase/components/Button.jsx";
 
 import EditUserForm from "../components/EditUserForm.jsx";
 import UserActionsSelect from "../components/UserActionsSelect.jsx";
@@ -181,14 +181,12 @@ export default class PeopleListingApp extends Component {
 
     renderAddPersonModal(modalDetails) {
         return (
-            <Modal onClose={this.onCloseModal}>
-                <ModalContent title="Add Person"
-                              closeFn={this.onCloseModal}>
-                    <EditUserForm
-                        buttonText="Add Person"
-                        submitFn={this.onAddPerson.bind(this)}
-                        groups={this.props.groups}/>
-                </ModalContent>
+            <Modal title="Add Person" onClose={this.onCloseModal}>
+                <EditUserForm
+                    buttonText="Add Person"
+                    submitFn={this.onAddPerson.bind(this)}
+                    groups={this.props.groups}
+                />
             </Modal>
         );
     }
@@ -197,13 +195,11 @@ export default class PeopleListingApp extends Component {
         let { user } = modalDetails;
 
         return (
-            <Modal onClose={this.onCloseModal}>
-                <ModalContent title="Edit Details"
-                              closeFn={this.onCloseModal}>
-                    <EditUserForm
-                        user={user}
-                        submitFn={this.onEditDetails.bind(this)} />
-                </ModalContent>
+            <Modal full form title="Edit Details" onClose={this.onCloseModal}>
+                <EditUserForm
+                    user={user}
+                    submitFn={this.onEditDetails.bind(this)}
+                />
             </Modal>
         );
     }
@@ -212,28 +208,23 @@ export default class PeopleListingApp extends Component {
         let { user } = modalDetails;
 
         return (
-            <Modal className="Modal Modal--small" onClose={this.onCloseModal}>
-                <ModalContent title={user.first_name+" has been added"}
-                              closeFn={this.onCloseModal}
-                              className="Modal-content Modal-content--small NewForm">
-                    <div>
-                        <div className="px4 pb4">
-                            <div className="pb4">We couldn’t send them an email invitation,
-                            so make sure to tell them to log in using <span className="text-bold">{user.email} </span>
-                            and this password we’ve generated for them:</div>
-
-                            <PasswordReveal password={user.password} />
-
-                            <div style={{paddingLeft: "5em", paddingRight: "5em"}} className="pt4 text-centered">If you want to be able to send email invites, just go to the <Link to="/admin/settings/email" className="link text-bold">Email Settings</Link> page.</div>
-                        </div>
-
-                        <div className="Form-actions">
-                            <button className="Button Button--primary" onClick={this.onCloseModal}>Done</button>
-                            <span className="mx1">or</span>
-                            <a className="link text-bold" onClick={() => this.props.showModal({type: MODAL_ADD_PERSON})}>Add another person</a>
-                        </div>
-                    </div>
-                </ModalContent>
+            <Modal small
+                title={user.first_name+" has been added"}
+                footer={[
+                    <Button onClick={() => this.props.showModal({type: MODAL_ADD_PERSON})}>Add another person</Button>,
+                    <Button primary onClick={this.onCloseModal}>Done</Button>
+                ]}
+                onClose={this.onCloseModal}
+            >
+                <div className="px4 pb4">
+                    <div className="pb4">We couldn’t send them an email invitation,
+                    so make sure to tell them to log in using <span className="text-bold">{user.email} </span>
+                    and this password we’ve generated for them:</div>
+
+                    <PasswordReveal password={user.password} />
+
+                    <div style={{paddingLeft: "5em", paddingRight: "5em"}} className="pt4 text-centered">If you want to be able to send email invites, just go to the <Link to="/admin/settings/email" className="link text-bold">Email Settings</Link> page.</div>
+                </div>
             </Modal>
         );
     }
@@ -242,20 +233,15 @@ export default class PeopleListingApp extends Component {
         let { user } = modalDetails;
 
         return (
-            <Modal className="Modal Modal--small" onClose={this.onCloseModal}>
-                <ModalContent title={user.first_name+" has been added"}
-                              closeFn={this.onCloseModal}
-                              className="Modal-content Modal-content--small NewForm">
-                    <div>
-                        <div style={{paddingLeft: "5em", paddingRight: "5em"}} className="pb4">We’ve sent an invite to <span className="text-bold">{user.email}</span> with instructions to set their password.</div>
-
-                        <div className="Form-actions">
-                            <button className="Button Button--primary" onClick={this.onCloseModal}>Done</button>
-                            <span className="mx1">or</span>
-                            <a className="link text-bold" onClick={() => this.props.showModal({type: MODAL_ADD_PERSON})}>Add another person</a>
-                        </div>
-                    </div>
-                </ModalContent>
+            <Modal small
+                title={user.first_name+" has been added"}
+                footer={[
+                    <Button onClick={() => this.props.showModal({type: MODAL_ADD_PERSON})}>Add another person</Button>,
+                    <Button primary onClick={this.onCloseModal}>Done</Button>
+                ]}
+                onClose={this.onCloseModal}
+            >
+                <div style={{paddingLeft: "5em", paddingRight: "5em"}} className="pb4">We’ve sent an invite to <span className="text-bold">{user.email}</span> with instructions to set their password.</div>
             </Modal>
         );
     }
@@ -264,18 +250,14 @@ export default class PeopleListingApp extends Component {
         let { user } = modalDetails;
 
         return (
-            <Modal className="Modal Modal--small" onClose={this.onCloseModal}>
-                <ModalContent title={"We've Re-sent "+user.first_name+"'s Invite"}
-                              closeFn={this.onCloseModal}
-                              className="Modal-content Modal-content--small NewForm">
-                    <div>
-                        <div className="px4 pb4">Any previous email invites they have will no longer work.</div>
-
-                        <div className="Form-actions">
-                            <button className="Button Button--primary mr2" onClick={this.onCloseModal}>Okay</button>
-                        </div>
-                    </div>
-                </ModalContent>
+            <Modal small form
+                title={"We've Re-sent "+user.first_name+"'s Invite"}
+                footer={[
+                    <Button primary onClick={this.onCloseModal}>Okay</Button>
+                ]}
+                onClose={this.onCloseModal}
+            >
+                <div>Any previous email invites they have will no longer work.</div>
             </Modal>
         );
     }
@@ -284,21 +266,17 @@ export default class PeopleListingApp extends Component {
         let { user } = modalDetails;
 
         return (
-            <Modal className="Modal Modal--small" onClose={this.onCloseModal}>
-                <ModalContent title={"Remove "+user.common_name}
-                              closeFn={this.onCloseModal}
-                              className="Modal-content Modal-content--small NewForm">
-                    <div>
-                        <div className="px4 pb4">
-                            Are you sure you want to do this? {user.first_name} won't be able to log in anymore.  This can't be undone.
-                        </div>
-
-                        <div className="Form-actions">
-                            <button className="Button Button--warning" onClick={() => this.onRemoveUserConfirm(user)}>Yes</button>
-                            <button className="Button Button--primary ml2" onClick={this.onCloseModal}>No</button>
-                        </div>
-                    </div>
-                </ModalContent>
+            <Modal small
+                title={"Remove "+user.common_name}
+                footer={[
+                    <Button onClick={this.onCloseModal}>Cancel</Button>,
+                    <Button warning onClick={() => this.onRemoveUserConfirm(user)}>Remove</Button>
+                ]}
+                onClose={this.onCloseModal}
+            >
+                <div className="px4 pb4">
+                    Are you sure you want to do this? {user.first_name} won't be able to log in anymore.  This can't be undone.
+                </div>
             </Modal>
         );
     }
@@ -307,21 +285,17 @@ export default class PeopleListingApp extends Component {
         let { user } = modalDetails;
 
         return (
-            <Modal className="Modal Modal--small" onClose={this.onCloseModal}>
-                <ModalContent title={"Reset "+user.first_name+"'s Password"}
-                              closeFn={this.onCloseModal}
-                              className="Modal-content Modal-content--small NewForm">
-                    <div>
-                        <div className="px4 pb4">
-                            Are you sure you want to do this?
-                        </div>
-
-                        <div className="Form-actions">
-                            <button className="Button Button--warning" onClick={() => this.onPasswordResetConfirm(user)}>Yes</button>
-                            <button className="Button Button--primary ml2" onClick={this.onCloseModal}>No</button>
-                        </div>
-                    </div>
-                </ModalContent>
+            <Modal small
+                title={"Reset "+user.first_name+"'s Password"}
+                footer={[
+                    <Button onClick={this.onCloseModal}>Cancel</Button>,
+                    <Button warning onClick={() => this.onPasswordResetConfirm(user)}>Reset</Button>
+                ]}
+                onClose={this.onCloseModal}
+            >
+                <div className="px4 pb4">
+                    Are you sure you want to do this?
+                </div>
             </Modal>
         );
     }
@@ -330,22 +304,16 @@ export default class PeopleListingApp extends Component {
         let { user, password } = modalDetails;
 
         return (
-            <Modal className="Modal Modal--small" onClose={this.onCloseModal}>
-                <ModalContent title={user.first_name+"'s Password Has Been Reset"}
-                              closeFn={this.onCloseModal}
-                              className="Modal-content Modal-content--small NewForm">
-                    <div>
-                        <div className="px4 pb4">
-                            <span className="pb3 block">Here’s a temporary password they can use to log in and then change their password.</span>
-
-                            <PasswordReveal password={password} />
-                        </div>
-
-                        <div className="Form-actions">
-                            <button className="Button Button--primary mr2" onClick={this.onCloseModal}>Done</button>
-                        </div>
-                    </div>
-                </ModalContent>
+            <Modal small
+                title={user.first_name+"'s Password Has Been Reset"}
+                footer={<button className="Button Button--primary mr2" onClick={this.onCloseModal}>Done</button>}
+                onClose={this.onCloseModal}
+            >
+                <div className="px4 pb4">
+                    <span className="pb3 block">Here’s a temporary password they can use to log in and then change their password.</span>
+
+                    <PasswordReveal password={password} />
+                </div>
             </Modal>
         );
     }
@@ -354,18 +322,13 @@ export default class PeopleListingApp extends Component {
         let { user } = modalDetails;
 
         return (
-            <Modal className="Modal Modal--small" onClose={this.onCloseModal}>
-                <ModalContent title={user.first_name+"'s Password Has Been Reset"}
-                              closeFn={this.onCloseModal}
-                              className="Modal-content Modal-content--small NewForm">
-                    <div>
-                        <div className="px4 pb4">We've sent them an email with instructions for creating a new password.</div>
-
-                        <div className="Form-actions">
-                            <button className="Button Button--primary mr2" onClick={this.onCloseModal}>Done</button>
-                        </div>
-                    </div>
-                </ModalContent>
+            <Modal
+                small
+                title={user.first_name+"'s Password Has Been Reset"}
+                footer={<Button primary onClick={this.onCloseModal}>Done</Button>}
+                onClose={this.onCloseModal}
+            >
+                <div className="px4 pb4">We've sent them an email with instructions for creating a new password.</div>
             </Modal>
         );
     }
diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx
index 973464077d7d520287ba53a47532573b8159cb16..b9ad99a77e491846b8c54f50b04935720093326c 100644
--- a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx
+++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx
@@ -2,56 +2,91 @@ import React, { Component, PropTypes } from "react";
 
 import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx";
 import Confirm from "metabase/components/Confirm.jsx";
+import Modal from "metabase/components/Modal.jsx";
 import PermissionsGrid from "../components/PermissionsGrid.jsx";
 import PermissionsConfirm from "../components/PermissionsConfirm.jsx";
 import EditBar from "metabase/components/EditBar.jsx";
 import Breadcrumbs from "metabase/components/Breadcrumbs.jsx";
+import Button from "metabase/components/Button";
 
 import cx from "classnames";
 
-const PermissionsEditor = ({ grid, onUpdatePermission, onSave, onCancel, isDirty, saveError, diff }) =>
-    <LoadingAndErrorWrapper loading={!grid} className="flex-full flex flex-column">
-    { () => // eslint-disable-line react/display-name
-        <div className="flex-full flex flex-column">
-            { isDirty &&
-                <EditBar
-                    admin
-                    title="You've made changes to permissions."
-                    buttons={[
-                        <Confirm
-                            title="Discard changes?"
-                            action={onCancel}
-                            content="No changes to permissions will be made."
-                        >
-                            <button className="Button Button--borderless">
-                                Cancel
-                            </button>
-                        </Confirm>,
-                        <Confirm
-                            title="Save permissions?"
-                            action={onSave}
-                            content={<PermissionsConfirm diff={diff} />}
-                            triggerClasses={cx({ disabled: !isDirty })}
-                        >
-                            <button className={cx("Button")}>Save Changes</button>
-                        </Confirm>
-                    ]}
+import _ from "underscore";
+
+const PermissionsEditor = ({ title = "Permissions", modal, admin, grid, onUpdatePermission, onSave, onCancel, confirmCancel, isDirty, saveError, diff, location }) => {
+    const saveButton =
+        <Confirm
+            title="Save permissions?"
+            action={onSave}
+            content={<PermissionsConfirm diff={diff} />}
+            triggerClasses={cx({ disabled: !isDirty })}
+        >
+            <Button primary small={!modal}>Save Changes</Button>
+        </Confirm>;
+
+    const cancelButton = confirmCancel ?
+        <Confirm
+            title="Discard changes?"
+            action={onCancel}
+            content="No changes to permissions will be made."
+        >
+            <Button small={!modal}>Cancel</Button>
+        </Confirm>
+    :
+        <Button small={!modal} onClick={onCancel}>Cancel</Button>;
+
+    return (
+        <LoadingAndErrorWrapper loading={!grid} className="flex-full flex flex-column">
+        { () => // eslint-disable-line react/display-name
+        modal ?
+            <Modal inline title={title} footer={[cancelButton, saveButton]} onClose={onCancel}>
+                <PermissionsGrid
+                    className="flex-full"
+                    grid={grid}
+                    onUpdatePermission={onUpdatePermission}
+                    {...getEntityAndGroupIdFromLocation(location)}
                 />
-            }
-            <div className="wrapper pt2">
-                { grid && grid.crumbs ?
-                    <Breadcrumbs className="py1" crumbs={grid.crumbs} />
-                :
-                    <h2>Permissions</h2>
+            </Modal>
+        :
+            <div className="flex-full flex flex-column">
+                { isDirty &&
+                    <EditBar
+                        admin={admin}
+                        title="You've made changes to permissions."
+                        buttons={[cancelButton, saveButton]}
+                    />
                 }
+                <div className="wrapper pt2">
+                    { grid && grid.crumbs ?
+                        <Breadcrumbs className="py1" crumbs={grid.crumbs} />
+                    :
+                        <h2>{title}</h2>
+                    }
+                </div>
+                <PermissionsGrid
+                    className="flex-full"
+                    grid={grid}
+                    onUpdatePermission={onUpdatePermission}
+                    {...getEntityAndGroupIdFromLocation(location)}
+                />
             </div>
-            <PermissionsGrid
-                className="flex-full"
-                grid={grid}
-                onUpdatePermission={onUpdatePermission}
-            />
-        </div>
-    }
-    </LoadingAndErrorWrapper>
+        }
+        </LoadingAndErrorWrapper>
+    )
+}
+
+PermissionsEditor.defaultProps = {
+    admin: true
+}
+
+function getEntityAndGroupIdFromLocation({ query = {}} = {}) {
+    query = _.mapObject(query, (value) => isNaN(value) ? value : parseFloat(value));
+    const entityId = _.omit(query, "groupId");
+    const groupId = query.groupId;
+    return {
+        groupId: groupId || null,
+        entityId: Object.keys(entityId).length > 0 ? entityId : null
+    };
+}
 
 export default PermissionsEditor;
diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx
index 8ddb953c85288998bf1db04f94b5ca9a12d2f99b..cad0652cc54cdfe12f3b0daa55d499184f5170f1 100644
--- a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx
+++ b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx
@@ -37,82 +37,36 @@ const CELL_WIDTH = 246;
 const HEADER_HEIGHT = 65;
 const HEADER_WIDTH = 240;
 
-const PERMISSIONS_UI = {
-    "native": {
-        header: "SQL Queries"
-    },
-    "schemas": {
-        header: "Data Access"
-    },
-    "tables": {
-        header: "Data Access"
-    },
-    "fields": {
-        header: "Data Access",
-    }
+const DEFAULT_OPTION = {
+    icon: "unknown",
+    iconColor: "#9BA5B1",
+    bgColor: "#DFE8EA"
 };
 
-const OPTIONS_UI = {
-    "write": {
-        title: "Write raw queries",
-        tooltip: "Can write raw queries",
-        icon: "sql",
-        iconColor: "#9CC177",
-        bgColor: "#F6F9F2"
-    },
-    "read": {
-        title: "View raw queries",
-        tooltip: "Can view raw queries",
-        icon: "eye",
-        iconColor: "#F9D45C",
-        bgColor: "#FEFAEE"
-    },
-    "all": {
-        title: "Grant unrestricted access",
-        tooltip: "Unrestricted access",
-        icon: "check",
-        iconColor: "#9CC177",
-        bgColor: "#F6F9F2"
-    },
-    "controlled": {
-        title: "Limit access",
-        tooltip: "Limited access",
-        icon: "permissionsLimited",
-        iconColor: "#F9D45C",
-        bgColor: "#FEFAEE"
-    },
-    "none": {
-        title: "Revoke access",
-        tooltip: "No access",
-        icon: "close",
-        iconColor: "#EEA5A5",
-        bgColor: "#FDF3F3"
-    },
-    "unknown": {
-        icon: "unknown",
-        iconColor: "#9BA5B1",
-        bgColor: "#DFE8EA"
-    }
-}
-
-const getOptionUi = (option) =>
-    OPTIONS_UI[option] || { ...OPTIONS_UI.unknown, title: option };
-
 const GroupColumnHeader = ({ group, permissions, isLastColumn, isFirstColumn }) =>
     <div className="absolute bottom left right">
-        <h4 className="text-centered full my1">{ group.name }</h4>
+        <h4 className="text-centered full my1 flex layout-centered">
+            { group.name }
+            { group.tooltip &&
+                <Tooltip tooltip={group.tooltip} maxWidth="24em">
+                    <Icon className="ml1" name="question" />
+                </Tooltip>
+            }
+        </h4>
         <div className="flex" style={getBorderStyles({ isLastColumn, isFirstColumn, isFirstRow: true, isLastRow: false })}>
             { permissions.map((permission, index) =>
-                <div key={permission.id} className="flex-full py1 border-column-divider" style={{
+                <div key={permission.id} className="flex-full border-column-divider" style={{
                     borderColor: LIGHT_BORDER,
                 }}>
-                    <h5 className="text-centered text-grey-3 text-uppercase text-light">{permission.header}</h5>
+                    { permission.header &&
+                        <h5 className="my1 text-centered text-grey-3 text-uppercase text-light">{permission.header}</h5>
+                    }
                 </div>
             )}
         </div>
     </div>
 
-const PermissionsCell = ({ group, permissions, entity, onUpdatePermission, isLastRow, isLastColumn, isFirstColumn }) =>
+const PermissionsCell = ({ group, permissions, entity, onUpdatePermission, isLastRow, isLastColumn, isFirstColumn, isFaded }) =>
     <div className="flex" style={getBorderStyles({ isLastRow, isLastColumn, isFirstColumn, isFirstRow: false })}>
         { permissions.map(permission =>
             <GroupPermissionCell
@@ -122,6 +76,7 @@ const PermissionsCell = ({ group, permissions, entity, onUpdatePermission, isLas
                 entity={entity}
                 onUpdatePermission={onUpdatePermission}
                 isEditable={group.editable}
+                isFaded={isFaded}
             />
         )}
     </div>
@@ -130,7 +85,7 @@ class GroupPermissionCell extends Component {
     constructor(props, context) {
         super(props, context);
         this.state = {
-            confirmText: null,
+            confirmations: null,
             confirmAction: null,
             hovered: false
         }
@@ -138,24 +93,27 @@ class GroupPermissionCell extends Component {
     hoverEnter () {
         // only change the hover state if the group is not the admin
         // this helps indicate to users that the admin group is different
-        if (this.props.group.name !== "Admin" ) {
+        if (this.props.isEditable) {
             return this.setState({ hovered: true });
         }
         return false
     }
     hoverExit () {
-        if (this.props.group.name !== "Admin" ) {
+        if (this.props.isEditable) {
             return this.setState({ hovered: false });
         }
         return false
     }
     render() {
-        const { permission, group, entity, onUpdatePermission } = this.props;
+        const { permission, group, entity, onUpdatePermission, isFaded } = this.props;
+        const { confirmations } = this.state;
 
         const value = permission.getter(group.id, entity.id);
         const options = permission.options(group.id, entity.id);
+        const warning = permission.warning && permission.warning(group.id, entity.id);
 
-        let isEditable = this.props.isEditable && options.filter(option => option !== value).length > 0;
+        let isEditable = this.props.isEditable && options.filter(option => option.value !== value).length > 0;
+        const option = _.findWhere(options, { value }) || DEFAULT_OPTION;
 
         return (
                 <PopoverWithTrigger
@@ -163,35 +121,47 @@ class GroupPermissionCell extends Component {
                     disabled={!isEditable}
                     triggerClasses="cursor-pointer flex flex-full layout-centered border-column-divider"
                     triggerElement={
-                        <Tooltip tooltip={getOptionUi(value).tooltip}>
+                        <Tooltip tooltip={option.tooltip}>
                             <div
-                                className={cx(
-                                    'flex-full flex layout-centered',
-                                    { 'cursor-pointer' : group.name !== 'Admin' },
-                                    { 'disabled' : group.name === 'Admin'}
-                                )}
+                                className={cx('flex-full flex layout-centered relative', {
+                                    'cursor-pointer' : isEditable,
+                                    faded: isFaded
+                                })}
                                 style={{
                                     borderColor: LIGHT_BORDER,
                                     height: CELL_HEIGHT - 1,
-                                    backgroundColor: this.state.hovered ? getOptionUi(value).iconColor : getOptionUi(value).bgColor,
+                                    backgroundColor: this.state.hovered ? option.iconColor : option.bgColor,
                                 }}
                                 onMouseEnter={() => this.hoverEnter()}
                                 onMouseLeave={() => this.hoverExit()}
                             >
                                 <Icon
-                                    name={getOptionUi(value).icon}
+                                    name={option.icon}
                                     size={28}
-                                    style={{ color: this.state.hovered ? '#fff' : getOptionUi(value).iconColor }}
+                                    style={{ color: this.state.hovered ? '#fff' : option.iconColor }}
                                 />
-                                { this.state.confirmText &&
+                                { confirmations && confirmations.length > 0 &&
                                     <Modal>
                                         <ConfirmContent
-                                            {...this.state.confirmText}
-                                            onAction={this.state.confirmAction}
-                                            onClose={() => this.setState({ confirmText: null, confirmAction: null })}
+                                            {...confirmations[0]}
+                                            onAction={() =>
+                                                // if it's the last one call confirmAction, otherwise remove the confirmation that was just confirmed
+                                                confirmations.length === 1 ?
+                                                    this.setState({ confirmations: null, confirmAction: null }, this.state.confirmAction)
+                                                :
+                                                    this.setState({ confirmations: confirmations.slice(1) })
+                                            }
+                                            onCancel={() => this.setState({ confirmations: null, confirmAction: null })}
                                         />
                                     </Modal>
                                 }
+                                { warning &&
+                                    <div className="absolute top right p1">
+                                        <Tooltip tooltip={warning} maxWidth="24em">
+                                            <Icon name="warning2" className="text-slate" />
+                                        </Tooltip>
+                                    </div>
+                                }
                             </div>
                         </Tooltip>
                    }
@@ -210,9 +180,9 @@ class GroupPermissionCell extends Component {
                                     postAction: permission.postAction
                                 })
                             }
-                            let confirmText = permission.confirm && permission.confirm(group.id, entity.id, value);
-                            if (confirmText) {
-                                this.setState({ confirmText, confirmAction });
+                            let confirmations = (permission.confirm && permission.confirm(group.id, entity.id, value) || []).filter(c => c);
+                            if (confirmations.length > 0) {
+                                this.setState({ confirmations, confirmAction });
                             } else {
                                 confirmAction();
                             }
@@ -229,18 +199,18 @@ const AccessOption = ({ value, option, onChange }) =>
         className={cx("flex py2 px2 align-center bg-brand-hover text-white-hover cursor-pointer", {
             "bg-brand text-white": value === option
         })}
-        onClick={() => onChange(option)}
+        onClick={() => onChange(option.value)}
     >
-        <Icon name={getOptionUi(option).icon} className="mr1" style={{ color: getOptionUi(option).iconColor }} size={18} />
-        {getOptionUi(option).title}
+        <Icon name={option.icon} className="mr1" style={{ color: option.iconColor }} size={18} />
+        {option.title}
     </div>
 
 const AccessOptionList = ({ value, options, onChange }) =>
     <ul className="py1">
         { options.map(option => {
-            if( value !== option ) {
+            if( value !== option.value ) {
                 return (
-                    <li key={option}>
+                    <li key={option.value}>
                         <AccessOption value={value} option={option} onChange={onChange} />
                     </li>
                )
@@ -275,9 +245,11 @@ const CornerHeader = ({ grid }) =>
         </div>
     </div>
 
-const PermissionsGrid = ({ className, grid, onUpdatePermission }) => {
+import _ from "underscore";
+
+const PermissionsGrid = ({ className, grid, onUpdatePermission, entityId, groupId }) => {
     const permissions = Object.entries(grid.permissions).map(([id, permission]) =>
-        ({ id: id, ...PERMISSIONS_UI[id], ...permission })
+        ({ id: id, ...permission })
     );
     return (
         <div className={className}>
@@ -304,6 +276,10 @@ const PermissionsGrid = ({ className, grid, onUpdatePermission }) => {
                                 isLastRow={rowIndex === grid.entities.length - 1}
                                 isFirstColumn={columnIndex === 0}
                                 isLastColumn={columnIndex === grid.groups.length - 1}
+                                isFaded={
+                                    (groupId != null && grid.groups[columnIndex].id !== groupId) ||
+                                    (entityId != null && !_.isEqual(entityId, grid.entities[rowIndex].id))
+                                }
                             />
                         }
                         renderColumnHeader={({ columnIndex }) =>
diff --git a/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7e182b4de13545392ae959fe239ba70bbae08d33
--- /dev/null
+++ b/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx
@@ -0,0 +1,46 @@
+import React, { Component, PropTypes } from "react";
+import { connect } from "react-redux";
+
+import PermissionsEditor from "../components/PermissionsEditor.jsx";
+import PermissionsApp from "./PermissionsApp.jsx";
+
+import { CollectionsApi } from "metabase/services";
+
+import { getCollectionsPermissionsGrid, getIsDirty, getSaveError, getDiff } from "../selectors";
+import { updatePermission, savePermissions, loadCollections } from "../permissions";
+import { goBack, push } from "react-router-redux";
+
+const mapStateToProps = (state, props) => {
+    return {
+        grid: getCollectionsPermissionsGrid(state, props),
+        isDirty: getIsDirty(state, props),
+        saveError: getSaveError(state, props),
+        diff: getDiff(state, props)
+    }
+}
+
+const mapDispatchToProps = {
+    onUpdatePermission: updatePermission,
+    onSave: savePermissions,
+    onCancel: () => window.history.length > 1 ? goBack() : push("/questions")
+};
+
+const Editor = connect(mapStateToProps, mapDispatchToProps)(PermissionsEditor);
+
+@connect(null, { loadCollections })
+export default class CollectionsPermissionsApp extends Component {
+    componentWillMount() {
+        this.props.loadCollections();
+    }
+    render() {
+        return (
+            <PermissionsApp
+                {...this.props}
+                load={CollectionsApi.graph}
+                save={CollectionsApi.updateGraph}
+            >
+                <Editor {...this.props} modal confirmCancel={false} />
+            </PermissionsApp>
+        )
+    }
+}
diff --git a/frontend/src/metabase/admin/permissions/containers/DataPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/DataPermissionsApp.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..823d6a46f979650ed70e1d3fe77bfd92de05dd3e
--- /dev/null
+++ b/frontend/src/metabase/admin/permissions/containers/DataPermissionsApp.jsx
@@ -0,0 +1,23 @@
+import React, { Component, PropTypes } from "react";
+import { connect } from "react-redux"
+
+import PermissionsApp from "./PermissionsApp.jsx";
+
+import { PermissionsApi } from "metabase/services";
+import { loadMetadata } from "../permissions";
+
+@connect(null, { loadMetadata })
+export default class DataPermissionsApp extends Component {
+    componentWillMount() {
+        this.props.loadMetadata();
+    }
+    render() {
+        return (
+            <PermissionsApp
+                {...this.props}
+                load={PermissionsApi.graph}
+                save={PermissionsApi.updateGraph}
+            />
+        );
+    }
+}
diff --git a/frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx
index bf899c6275ef06a2288d9adf1abd210567313fdf..f4afd2bb54eab5cc306ef6240d03de9faeec367c 100644
--- a/frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx
+++ b/frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx
@@ -21,6 +21,11 @@ const mapDispatchToProps = {
 @withRouter
 @connect(mapStateToProps, mapDispatchToProps)
 export default class PermissionsApp extends Component {
+    static propTypes = {
+        load: PropTypes.func.isRequired,
+        save: PropTypes.func.isRequired
+    };
+
     constructor(props, context) {
         super(props, context);
         this.state = {
@@ -29,7 +34,7 @@ export default class PermissionsApp extends Component {
         }
     }
     componentWillMount() {
-        this.props.initialize();
+        this.props.initialize(this.props.load, this.props.save);
         this.props.router.setRouteLeaveHook(
             this.props.route,
             this.routerWillLeave
diff --git a/frontend/src/metabase/admin/permissions/permissions.js b/frontend/src/metabase/admin/permissions/permissions.js
index bb36b50a48f3bba7ed594777387c4574719cfb7b..0973b926ca316cd9617c62bf029dfeeb9c29e524 100644
--- a/frontend/src/metabase/admin/permissions/permissions.js
+++ b/frontend/src/metabase/admin/permissions/permissions.js
@@ -3,27 +3,41 @@ import { createAction, createThunkAction, handleActions, combineReducers } from
 import { canEditPermissions } from "metabase/lib/groups";
 import MetabaseAnalytics from "metabase/lib/analytics";
 
-import { MetabaseApi, PermissionsApi } from "metabase/services";
+import { MetabaseApi, PermissionsApi, CollectionsApi } from "metabase/services";
+
+const RESET = "metabase/admin/permissions/RESET";
+export const reset = createAction(RESET);
 
 const INITIALIZE = "metabase/admin/permissions/INITIALIZE";
-export const initialize = createThunkAction(INITIALIZE, () =>
+export const initialize = createThunkAction(INITIALIZE, (load, save) =>
     async (dispatch, getState) => {
+        dispatch(reset({ load, save }));
         await Promise.all([
             dispatch(loadPermissions()),
             dispatch(loadGroups()),
-            dispatch(loadMetadata())
         ]);
     }
 );
 
-const LOAD_PERMISSIONS = "metabase/admin/permissions/LOAD_PERMISSIONS";
-export const loadPermissions = createAction(LOAD_PERMISSIONS, () => PermissionsApi.graph());
+// TODO: move these to their respective ducks
+const LOAD_METADATA = "metabase/admin/permissions/LOAD_METADATA";
+export const loadMetadata = createAction(LOAD_METADATA, () => MetabaseApi.db_list_with_tables());
+
+// TODO: move these to their respective ducks
+const LOAD_COLLECTIONS = "metabase/admin/permissions/LOAD_COLLECTIONS";
+export const loadCollections = createAction(LOAD_COLLECTIONS, () => CollectionsApi.list());
+
 
 const LOAD_GROUPS = "metabase/admin/permissions/LOAD_GROUPS";
 export const loadGroups = createAction(LOAD_GROUPS, () => PermissionsApi.groups());
 
-const LOAD_METADATA = "metabase/admin/permissions/LOAD_METADATA";
-export const loadMetadata = createAction(LOAD_METADATA, () => MetabaseApi.db_list_with_tables());
+const LOAD_PERMISSIONS = "metabase/admin/permissions/LOAD_PERMISSIONS";
+export const loadPermissions = createThunkAction(LOAD_PERMISSIONS, () =>
+    async (dispatch, getState) => {
+        const { load } = getState().permissions;
+        return load();
+    }
+);
 
 const UPDATE_PERMISSION = "metabase/admin/permissions/UPDATE_PERMISSION";
 export const updatePermission = createThunkAction(UPDATE_PERMISSION, ({ groupId, entityId, value, updater, postAction }) =>
@@ -42,8 +56,8 @@ const SAVE_PERMISSIONS = "metabase/admin/permissions/SAVE_PERMISSIONS";
 export const savePermissions = createThunkAction(SAVE_PERMISSIONS, () =>
     async (dispatch, getState) => {
         MetabaseAnalytics.trackEvent("Permissions", "save");
-        const { permissions, revision } = getState().permissions;
-        let result = await PermissionsApi.updateGraph({
+        const { permissions, revision, save } = getState().permissions;
+        let result = await save({
             revision: revision,
             groups: permissions
         });
@@ -51,19 +65,28 @@ export const savePermissions = createThunkAction(SAVE_PERMISSIONS, () =>
     }
 )
 
+const save = handleActions({
+    [RESET]: { next: (state, { payload }) => payload.save }
+}, null);
+const load = handleActions({
+    [RESET]: { next: (state, { payload }) => payload.load }
+}, null);
 
 const permissions = handleActions({
+    [RESET]: { next: () => null },
     [LOAD_PERMISSIONS]: { next: (state, { payload }) => payload.groups },
     [SAVE_PERMISSIONS]: { next: (state, { payload }) => payload.groups },
     [UPDATE_PERMISSION]: { next: (state, { payload }) => payload }
 }, null);
 
 const originalPermissions = handleActions({
+    [RESET]: { next: () => null },
     [LOAD_PERMISSIONS]: { next: (state, { payload }) => payload.groups },
     [SAVE_PERMISSIONS]: { next: (state, { payload }) => payload.groups },
 }, null);
 
 const revision = handleActions({
+    [RESET]: { next: () => null },
     [LOAD_PERMISSIONS]: { next: (state, { payload }) => payload.revision },
     [SAVE_PERMISSIONS]: { next: (state, { payload }) => payload.revision },
 }, null);
@@ -81,7 +104,12 @@ const databases = handleActions({
     [LOAD_METADATA]: { next: (state, { payload }) => payload },
 }, null);
 
+const collections = handleActions({
+    [LOAD_COLLECTIONS]: { next: (state, { payload }) => payload },
+}, null);
+
 const saveError = handleActions({
+    [RESET]: { next: () => null },
     [SAVE_PERMISSIONS]: {
         next: (state) => null,
         throw: (state, { payload }) => (payload && typeof payload.data === "string" ? payload.data : payload.data.message) || "Sorry, an error occurred."
@@ -92,10 +120,15 @@ const saveError = handleActions({
 }, null);
 
 export default combineReducers({
+    save,
+    load,
+
     permissions,
     originalPermissions,
     saveError,
     revision,
     groups,
-    databases
+
+    databases,
+    collections
 });
diff --git a/frontend/src/metabase/admin/permissions/routes.jsx b/frontend/src/metabase/admin/permissions/routes.jsx
index a90e3596092253dd4777b3543eb29c7ce6a2f8a9..2b976fe0dcb8495870b2d628f7e8ce956dfd51cb 100644
--- a/frontend/src/metabase/admin/permissions/routes.jsx
+++ b/frontend/src/metabase/admin/permissions/routes.jsx
@@ -1,13 +1,13 @@
 import React, { Component, PropTypes } from "react";
 import { Route, IndexRedirect } from 'react-router';
 
-import PermissionsApp from "./containers/PermissionsApp.jsx";
+import DataPermissionsApp from "./containers/DataPermissionsApp.jsx";
 import DatabasesPermissionsApp from "./containers/DatabasesPermissionsApp.jsx";
 import SchemasPermissionsApp from "./containers/SchemasPermissionsApp.jsx";
 import TablesPermissionsApp from "./containers/TablesPermissionsApp.jsx";
 
 const getRoutes = (store) =>
-    <Route path="permissions" component={PermissionsApp}>
+    <Route path="permissions" component={DataPermissionsApp}>
         <IndexRedirect to="databases" />
         <Route path="databases" component={DatabasesPermissionsApp} />
         <Route path="databases/:databaseId/schemas" component={SchemasPermissionsApp} />
diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js
index f12b05850a86a0dad9fd73dd509be8a606780370..47fd7ae7f4b3a7219141643f729821454b9f21e6 100644
--- a/frontend/src/metabase/admin/permissions/selectors.js
+++ b/frontend/src/metabase/admin/permissions/selectors.js
@@ -13,6 +13,7 @@ import type { Group, GroupsPermissions } from "metabase/meta/types/Permissions";
 
 import { isDefaultGroup, isAdminGroup, isMetaBotGroup } from "metabase/lib/groups";
 import _ from "underscore";
+import { getIn, assocIn } from "icepick";
 
 import {
     getNativePermission,
@@ -40,6 +41,17 @@ const getMetadata = createSelector(
 // reorder groups to be in this order
 const SPECIAL_GROUP_FILTERS = [isAdminGroup, isDefaultGroup, isMetaBotGroup].reverse();
 
+function getTooltipForGroup(group) {
+    if (isAdminGroup(group)) {
+        return "Administrators always have the highest level of acess to everything in Metabase."
+    } else if (isDefaultGroup(group)) {
+        return "Every Metabase user belongs to the All Users group. If you want to limit or restrict a group's access to something, make sure the All Users group has an equal or lower level of access.";
+    } else if (isMetaBotGroup(group)) {
+        return "Metabot is Metabase's Slack bot. You can choose what it has access to here.";
+    }
+    return null;
+}
+
 export const getGroups = createSelector(
     (state) => state.permissions.groups,
     (groups) => {
@@ -50,7 +62,10 @@ export const getGroups = createSelector(
                 orderedGroups.unshift(...orderedGroups.splice(index, 1))
             }
         }
-        return orderedGroups;
+        return orderedGroups.map(group => ({
+            ...group,
+            tooltip: getTooltipForGroup(group)
+        }))
     }
 );
 
@@ -62,6 +77,132 @@ export const getIsDirty = createSelector(
 
 export const getSaveError = (state) => state.permissions.saveError;
 
+
+// these are all the permission levels ordered by level of access
+const PERM_LEVELS = ["write", "read", "all", "controlled", "none"];
+function hasGreaterPermissions(a, b) {
+    return (PERM_LEVELS.indexOf(a) - PERM_LEVELS.indexOf(b)) < 0
+}
+
+function getPermissionWarning(getter, entityType, defaultGroup, permissions, groupId, entityId, value) {
+    if (!defaultGroup || groupId === defaultGroup.id) {
+        return null;
+    }
+    let perm = value || getter(permissions, groupId, entityId);
+    let defaultPerm = getter(permissions, defaultGroup.id, entityId);
+    if (perm === "controlled" && defaultPerm === "controlled") {
+        return `The "${defaultGroup.name}" group may have access to a different set of ${entityType} than this group, which may give this group additional access to some ${entityType}.`;
+    }
+    if (hasGreaterPermissions(defaultPerm, perm)) {
+        return `The "${defaultGroup.name}" group has a higher level of access than this, which will override this setting. You should limit or revoke the "${defaultGroup.name}" group's access to this item.`;
+    }
+    return null;
+}
+
+function getPermissionWarningModal(entityType, getter, defaultGroup, permissions, groupId, entityId, value) {
+    let permissionWarning = getPermissionWarning(entityType, getter, defaultGroup, permissions, groupId, entityId, value);
+    if (permissionWarning) {
+        return {
+            title: `${value === "controlled" ? "Limit" : "Revoke"} access even though "${defaultGroup.name}" has greater access?`,
+            message: permissionWarning,
+            confirmButtonText: (value === "controlled" ? "Limit" : "Revoke") + " access",
+            cancelButtonText: "Cancel"
+        };
+    }
+}
+
+function getControlledDatabaseWarningModal(permissions, groupId, entityId) {
+    if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") {
+        return {
+            title: "Changing this database to limited access",
+            confirmButtonText: "Change",
+            cancelButtonText: "Cancel"
+        };
+    }
+}
+
+function getRawQueryWarningModal(permissions, groupId, entityId, value) {
+    if (value === "write" &&
+        getNativePermission(permissions, groupId, entityId) !== "write" &&
+        getSchemasPermission(permissions, groupId, entityId) !== "all"
+    ) {
+        return {
+            title: "Allow Raw Query Writing?",
+            message: "This will also change this group's data access to Unrestricted for this database.",
+            confirmButtonText: "Allow",
+            cancelButtonText: "Cancel"
+        };
+    }
+}
+
+const OPTION_GREEN = {
+    icon: "check",
+    iconColor: "#9CC177",
+    bgColor: "#F6F9F2"
+};
+const OPTION_YELLOW = {
+    icon: "eye",
+    iconColor: "#F9D45C",
+    bgColor: "#FEFAEE"
+};
+const OPTION_RED = {
+    icon: "close",
+    iconColor: "#EEA5A5",
+    bgColor: "#FDF3F3"
+};
+
+
+const OPTION_ALL = {
+    ...OPTION_GREEN,
+    value: "all",
+    title: "Grant unrestricted access",
+    tooltip: "Unrestricted access",
+};
+
+const OPTION_CONTROLLED = {
+    ...OPTION_YELLOW,
+    value: "controlled",
+    title: "Limit access",
+    tooltip: "Limited access",
+    icon: "permissionsLimited",
+};
+
+const OPTION_NONE = {
+    ...OPTION_RED,
+    value: "none",
+    title: "Revoke access",
+    tooltip: "No access",
+};
+
+const OPTION_NATIVE_WRITE = {
+    ...OPTION_GREEN,
+    value: "write",
+    title: "Write raw queries",
+    tooltip: "Can write raw queries",
+    icon: "sql",
+};
+
+const OPTION_NATIVE_READ = {
+    ...OPTION_YELLOW,
+    value: "read",
+    title: "View raw queries",
+    tooltip: "Can view raw queries",
+};
+
+const OPTION_COLLECTION_WRITE = {
+    ...OPTION_GREEN,
+    value: "write",
+    title: "Curate collection",
+    tooltip: "Can add and remove questions from this collection",
+};
+
+const OPTION_COLLECTION_READ = {
+    ...OPTION_YELLOW,
+    value: "read",
+    title: "View collection",
+    tooltip: "Can view questions in this collection",
+};
+
 export const getTablesPermissionsGrid = createSelector(
     getMetadata, getGroups, getPermissions, getDatabaseId, getSchemaName,
     (metadata: Metadata, groups: Array<Group>, permissions: GroupsPermissions, databaseId: DatabaseId, schemaName: SchemaName) => {
@@ -72,6 +213,7 @@ export const getTablesPermissionsGrid = createSelector(
         }
 
         const tables = database.tablesInSchema(schemaName || null);
+        const defaultGroup = _.find(groups, isDefaultGroup);
 
         return {
             type: "table",
@@ -86,8 +228,9 @@ export const getTablesPermissionsGrid = createSelector(
             groups,
             permissions: {
                 "fields": {
+                    header: "Data Access",
                     options(groupId, entityId) {
-                        return ["all", "none"]
+                        return [OPTION_ALL, OPTION_NONE]
                     },
                     getter(groupId, entityId) {
                         return getFieldsPermission(permissions, groupId, entityId);
@@ -97,11 +240,13 @@ export const getTablesPermissionsGrid = createSelector(
                         return updateFieldsPermission(permissions, groupId, entityId, value, metadata);
                     },
                     confirm(groupId, entityId, value) {
-                        if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") {
-                            return {
-                                title: "Changing this database to limited access"
-                            };
-                        }
+                        return [
+                            getPermissionWarningModal(getFieldsPermission, "fields", defaultGroup, permissions, groupId, entityId, value),
+                            getControlledDatabaseWarningModal(permissions, groupId, entityId)
+                        ];
+                    },
+                    warning(groupId, entityId) {
+                        return getPermissionWarning(getFieldsPermission, "fields", defaultGroup, permissions, groupId, entityId);
                     }
                 }
             },
@@ -128,6 +273,7 @@ export const getSchemasPermissionsGrid = createSelector(
         }
 
         const schemaNames = database.schemaNames();
+        const defaultGroup = _.find(groups, isDefaultGroup);
 
         return {
             type: "schema",
@@ -137,9 +283,10 @@ export const getSchemasPermissionsGrid = createSelector(
             ],
             groups,
             permissions: {
+                header: "Data Access",
                 "tables": {
                     options(groupId, entityId) {
-                        return ["all", "controlled", "none"]
+                        return [OPTION_ALL, OPTION_CONTROLLED, OPTION_NONE]
                     },
                     getter(groupId, entityId) {
                         return getTablesPermission(permissions, groupId, entityId);
@@ -154,11 +301,13 @@ export const getSchemasPermissionsGrid = createSelector(
                         }
                     },
                     confirm(groupId, entityId, value) {
-                        if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") {
-                            return {
-                                title: "Changing this database to limited access"
-                            };
-                        }
+                        return [
+                            getPermissionWarningModal(getTablesPermission, "tables", defaultGroup, permissions, groupId, entityId, value),
+                            getControlledDatabaseWarningModal(permissions, groupId, entityId)
+                        ];
+                    },
+                    warning(groupId, entityId) {
+                        return getPermissionWarning(getTablesPermission, "tables", defaultGroup, permissions, groupId, entityId);
                     }
                 }
             },
@@ -182,14 +331,16 @@ export const getDatabasesPermissionsGrid = createSelector(
         }
 
         const databases = metadata.databases();
+        const defaultGroup = _.find(groups, isDefaultGroup);
 
         return {
             type: "database",
             groups,
             permissions: {
                 "schemas": {
+                    header: "Data Access",
                     options(groupId, entityId) {
-                        return ["all", "controlled", "none"]
+                        return [OPTION_ALL, OPTION_CONTROLLED, OPTION_NONE]
                     },
                     getter(groupId, entityId) {
                         return getSchemasPermission(permissions, groupId, entityId);
@@ -211,13 +362,22 @@ export const getDatabasesPermissionsGrid = createSelector(
                             }
                         }
                     },
+                    confirm(groupId, entityId, value) {
+                        return [
+                            getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value)
+                        ];
+                    },
+                    warning(groupId, entityId) {
+                        return getPermissionWarning(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId);
+                    }
                 },
                 "native": {
+                    header: "SQL Queries",
                     options(groupId, entityId) {
                         if (getSchemasPermission(permissions, groupId, entityId) === "none") {
-                            return ["none"];
+                            return [OPTION_NONE];
                         } else {
-                            return ["write", "read", "none"];
+                            return [OPTION_NATIVE_WRITE, OPTION_NATIVE_READ, OPTION_NONE];
                         }
                     },
                     getter(groupId, entityId) {
@@ -228,15 +388,13 @@ export const getDatabasesPermissionsGrid = createSelector(
                         return updateNativePermission(permissions, groupId, entityId, value, metadata);
                     },
                     confirm(groupId, entityId, value) {
-                        if (value === "write" &&
-                            getNativePermission(permissions, groupId, entityId) !== "write" &&
-                            getSchemasPermission(permissions, groupId, entityId) !== "all"
-                        ) {
-                            return {
-                                title: "Allow Raw Query Writing",
-                                message: "This will also change this group's data access to Unrestricted for this database."
-                            };
-                        }
+                        return [
+                            getPermissionWarningModal(getNativePermission, null, defaultGroup, permissions, groupId, entityId, value),
+                            getRawQueryWarningModal(permissions, groupId, entityId, value)
+                        ];
+                    },
+                    warning(groupId, entityId) {
+                        return getPermissionWarning(getNativePermission, null, defaultGroup, permissions, groupId, entityId);
                     }
                 },
             },
@@ -260,6 +418,56 @@ export const getDatabasesPermissionsGrid = createSelector(
     }
 );
 
+const getCollections = (state) => state.permissions.collections;
+const getCollectionPermission = (permissions, groupId, { collectionId }) =>
+    getIn(permissions, [groupId, collectionId])
+
+export const getCollectionsPermissionsGrid = createSelector(
+    getCollections, getGroups, getPermissions,
+    (collections, groups: Array<Group>, permissions: GroupsPermissions) => {
+        if (!groups || !permissions || !collections) {
+            return null;
+        }
+
+        const defaultGroup = _.find(groups, isDefaultGroup);
+
+        return {
+            type: "collection",
+            groups,
+            permissions: {
+                "access": {
+                    options(groupId, entityId) {
+                        return [OPTION_COLLECTION_WRITE, OPTION_COLLECTION_READ, OPTION_NONE];
+                    },
+                    getter(groupId, entityId) {
+                        return getCollectionPermission(permissions, groupId, entityId);
+                    },
+                    updater(groupId, { collectionId }, value) {
+                        return assocIn(permissions, [groupId, collectionId], value);
+                    },
+                    confirm(groupId, entityId, value) {
+                        return [
+                            getPermissionWarningModal(getCollectionPermission, null, defaultGroup, permissions, groupId, entityId, value)
+                        ];
+                    },
+                    warning(groupId, entityId) {
+                        return getPermissionWarning(getCollectionPermission, null, defaultGroup, permissions, groupId, entityId);
+                    }
+                },
+            },
+            entities: collections.map(collection => {
+                return {
+                    id: {
+                        collectionId: collection.id
+                    },
+                    name: collection.name
+                }
+            })
+        }
+    }
+);
+
+
 export const getDiff = createSelector(
     getMetadata, getGroups, getPermissions, getOriginalPermissions,
     (metadata: Metadata, groups: Array<Group>, permissions: GroupsPermissions, originalPermissions: GroupsPermissions) =>
diff --git a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx
index 6169537e4dc4ba5d5dbbf485a1575e786df87a42..c36cda10147a25d18ea928c93a7f4d1a30226093 100644
--- a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx
+++ b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx
@@ -147,7 +147,7 @@ export default class CustomGeoJSONWidget extends Component {
                     onDeleteMap={this._delete}
                 />
                 { this.state.map ?
-                    <Modal className="Modal Modal--wide">
+                    <Modal wide>
                         <div className="p4">
                             <EditMap
                                 map={this.state.map}
diff --git a/frontend/src/metabase/components/AccordianList.jsx b/frontend/src/metabase/components/AccordianList.jsx
index 80959e32caf11ad13d009c49d2cce63a46347bee..953be139c289362f20d0024ae4d7698ed6e4abd6 100644
--- a/frontend/src/metabase/components/AccordianList.jsx
+++ b/frontend/src/metabase/components/AccordianList.jsx
@@ -56,7 +56,7 @@ export default class AccordianList extends Component {
 
     static defaultProps = {
         style: {},
-        searchable: (section) => section.items.length > 10,
+        searchable: (section) => section.items && section.items.length > 10,
         alwaysTogglable: false,
         alwaysExpanded: false,
         hideSingleSectionTitle: false,
@@ -189,7 +189,7 @@ export default class AccordianList extends Component {
                                     <div className="List-section-header px1 py1 cursor-pointer full flex align-center" onClick={() => this.toggleSection(sectionIndex)}>
                                         { this.renderSectionIcon(section, sectionIndex) }
                                         <h3 className="List-section-title">{section.name}</h3>
-                                        { sections.length > 1 &&
+                                        { sections.length > 1 && section.items && section.items.length > 0 &&
                                             <span className="flex-align-right">
                                                 <Icon name={sectionIsOpen(sectionIndex) ? "chevronup" : "chevrondown"} size={12} />
                                             </span>
@@ -204,7 +204,7 @@ export default class AccordianList extends Component {
                             </div>
                         : null }
 
-                        { sectionIsSearchable(sectionIndex) &&  sectionIsOpen(sectionIndex) && section.items.length > 0 &&
+                        { sectionIsSearchable(sectionIndex) &&  sectionIsOpen(sectionIndex) && section.items && section.items.length > 0 &&
                             /* NOTE: much of this structure is here just to match strange stuff in 'List-item' below so things align properly */
                             <div className="px1 pt1">
                                 <div style={{border: "2px solid transparent", borderRadius: "6px"}}>
@@ -218,7 +218,7 @@ export default class AccordianList extends Component {
                             </div>
                         }
 
-                        { sectionIsOpen(sectionIndex) && section.items.length > 0 &&
+                        { sectionIsOpen(sectionIndex) && section.items && section.items.length > 0 &&
                             <ul
                                 style={{ maxHeight: alwaysExpanded ? undefined : 400}}
                                 className={cx("p1", { "border-bottom scroll-y scroll-show": !alwaysExpanded })}
diff --git a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx
index ce1aeb97b15122fad0a28eb17d857e4e70319ae5..408cb5777523ee39bdb95e3a7bce27f553055a8e 100644
--- a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx
+++ b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx
@@ -25,7 +25,7 @@ export default class AddToDashSelectDashModal extends Component {
 
     static propTypes = {
         card: PropTypes.object.isRequired,
-        closeFn: PropTypes.func.isRequired,
+        onClose: PropTypes.func.isRequired,
         onChangeLocation: PropTypes.func.isRequired
     };
 
@@ -54,13 +54,13 @@ export default class AddToDashSelectDashModal extends Component {
         if (!this.state.dashboards) {
             return null;
         } else if (this.state.dashboards.length === 0 || this.state.shouldCreateDashboard === true) {
-            return <CreateDashboardModal createDashboardFn={this.createDashboard} closeFn={this.props.closeFn} />
+            return <CreateDashboardModal createDashboardFn={this.createDashboard} onClose={this.props.onClose} />
         } else {
             return (
                 <ModalContent
                     id="AddToDashSelectDashModal"
                     title="Add Question to Dashboard"
-                    closeFn={this.props.closeFn}
+                    onClose={this.props.onClose}
                 >
                 <div className="flex flex-column">
                     <div
diff --git a/frontend/src/metabase/components/Alert.jsx b/frontend/src/metabase/components/Alert.jsx
index 0d365ec4f30ed717aea70f294c29771170e2b894..c9ee25c3bb51f163be88e7b620402ddbaa5d4932 100644
--- a/frontend/src/metabase/components/Alert.jsx
+++ b/frontend/src/metabase/components/Alert.jsx
@@ -3,7 +3,7 @@ import React, { Component, PropTypes } from "react";
 import Modal from "metabase/components/Modal.jsx";
 
 const Alert = ({ message, onClose }) =>
-    <Modal className="Modal Modal--small" isOpen={!!message}>
+    <Modal small isOpen={!!message}>
         <div className="flex flex-column layout-centered p4">
             <h3 className="mb4">{message}</h3>
             <button className="Button Button--primary" onClick={onClose}>Ok</button>
diff --git a/frontend/src/metabase/components/BodyComponent.jsx b/frontend/src/metabase/components/BodyComponent.jsx
index 631c01d7ffa5cddcb4523fb18ee81db7d6a02aee..11de27256f1648df07a49c7c7877c9939f7bb463 100644
--- a/frontend/src/metabase/components/BodyComponent.jsx
+++ b/frontend/src/metabase/components/BodyComponent.jsx
@@ -32,6 +32,6 @@ export default ComposedComponent => class extends Component {
     }
 
     render() {
-        return <span />;
+        return null;
     }
 };
diff --git a/frontend/src/metabase/components/Button.jsx b/frontend/src/metabase/components/Button.jsx
index 8ce1b897e0fd8ceb120c0a765eb6d1a7e7d11980..0691546ba78dc9ab4b8a50e0660d3f5cf176a5c6 100644
--- a/frontend/src/metabase/components/Button.jsx
+++ b/frontend/src/metabase/components/Button.jsx
@@ -21,7 +21,7 @@ const Button = ({ className, icon, children, ...props }) => {
     let variantClasses = BUTTON_VARIANTS.filter(variant => props[variant]).map(variant => "Button--" + variant);
     return (
         <button
-            {..._.omit(props, ...variantClasses)}
+            {..._.omit(props, ...BUTTON_VARIANTS)}
             className={cx("Button", className, variantClasses)}
         >
             <div className="flex layout-centered">
diff --git a/frontend/src/metabase/components/ColorPicker.jsx b/frontend/src/metabase/components/ColorPicker.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..184325747951a5309b2d1b0ecb7f50132d56c560
--- /dev/null
+++ b/frontend/src/metabase/components/ColorPicker.jsx
@@ -0,0 +1,64 @@
+import React, { Component } from "react";
+
+import Icon from "metabase/components/Icon";
+import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
+
+import { normal, saturated, desaturated } from "metabase/lib/colors";
+
+const COLORS = [
+    ...Object.values(normal),
+    ...Object.values(saturated),
+    ...Object.values(desaturated),
+];
+
+const COLOR_SQUARE_SIZE = 32;
+const COLOR_SQUARE = {
+    width: COLOR_SQUARE_SIZE,
+    height: COLOR_SQUARE_SIZE
+};
+
+const ColorSquare = ({ color }) =>
+    <div style={{
+        ...COLOR_SQUARE,
+        backgroundColor: color,
+        borderRadius: 4
+    }}></div>
+
+class ColorPicker extends Component {
+    render () {
+        const { value, onChange } = this.props;
+        return (
+            <div className="inline-block">
+                <PopoverWithTrigger
+                    ref="colorPopover"
+                    triggerElement={
+                        <div className="bordered p1 rounded flex align-center">
+                            <ColorSquare color={value} />
+                            <Icon
+                                className="ml1"
+                                name="chevrondown"
+                            />
+                        </div>
+                    }
+                >
+                    <ol className="flex p1">
+                        { COLORS.map((color, index) =>
+                            <li
+                                className="cursor-pointer mr1 mb1"
+                                key={index}
+                                onClick={() => {
+                                    onChange(color);
+                                    this.refs.colorPopover.close();
+                                }}
+                            >
+                                <ColorSquare color={color} />
+                            </li>
+                        )}
+                    </ol>
+                </PopoverWithTrigger>
+            </div>
+        );
+    }
+}
+
+export default ColorPicker;
diff --git a/frontend/src/metabase/components/ColumnarSelector.css b/frontend/src/metabase/components/ColumnarSelector.css
index 450c587f3a48150229d9e790d0500c7dfba2ce69..29afc4c9e8856c43e02447989a91df78b79c45d9 100644
--- a/frontend/src/metabase/components/ColumnarSelector.css
+++ b/frontend/src/metabase/components/ColumnarSelector.css
@@ -44,7 +44,8 @@
     align-items: center;
 }
 
-.ColumnarSelector-row:not(.ColumnarSelector-row--disabled):hover {
+.ColumnarSelector-row:not(.ColumnarSelector-row--disabled):hover,
+.ColumnarSelector-row:not(.ColumnarSelector-row--disabled):hover .Icon {
     background-color: var(--brand-color) !important;
     color: white !important;
 }
@@ -77,7 +78,8 @@
     z-index: 1;
 }
 
-.ColumnarSelector-column:last-child {
+/* only apply if there's more than one, aka the last is not the first */
+.ColumnarSelector-column:last-child:not(:first-child) {
     background-color: white;
     border-left:  var(--border-size) var(--border-style) var(--border-color);
     position: relative;
diff --git a/frontend/src/metabase/components/ConfirmContent.jsx b/frontend/src/metabase/components/ConfirmContent.jsx
index fcd21da1fab29c4d25889512372af0a46ff104c4..984d420db23c298abd2da2dc6f37e965ceb11dea 100644
--- a/frontend/src/metabase/components/ConfirmContent.jsx
+++ b/frontend/src/metabase/components/ConfirmContent.jsx
@@ -2,10 +2,21 @@ import React, { Component, PropTypes } from "react";
 
 import ModalContent from "metabase/components/ModalContent.jsx";
 
-const ConfirmContent = ({ title, content, onClose, onAction, message = "Are you sure you want to do this?" }) =>
+const nop = () => {};
+
+const ConfirmContent = ({
+    title,
+    content,
+    message = "Are you sure you want to do this?",
+    onClose = nop,
+    onAction = nop,
+    onCancel = nop,
+    confirmButtonText = "Yes",
+    cancelButtonText = "No"
+}) =>
     <ModalContent
         title={title}
-        closeFn={onClose}
+        onClose={() => { onCancel(); onClose(); }}
     >
         <div className="mx4">{content}</div>
 
@@ -14,8 +25,8 @@ const ConfirmContent = ({ title, content, onClose, onAction, message = "Are you
         </div>
 
         <div className="Form-actions">
-            <button className="Button Button--danger" onClick={() => { onAction(); onClose(); }}>Yes</button>
-            <button className="Button ml1" onClick={onClose}>No</button>
+            <button className="Button Button--danger" onClick={() => { onAction(); onClose(); }}>{confirmButtonText}</button>
+            <button className="Button ml1" onClick={() => { onCancel(); onClose(); }}>{cancelButtonText}</button>
         </div>
     </ModalContent>
 
diff --git a/frontend/src/metabase/components/CreateDashboardModal.jsx b/frontend/src/metabase/components/CreateDashboardModal.jsx
index a9d15ec01dea20f3eed670f3ed7821764330b941..e39425df144470eefa1301fc06f55246d34c542f 100644
--- a/frontend/src/metabase/components/CreateDashboardModal.jsx
+++ b/frontend/src/metabase/components/CreateDashboardModal.jsx
@@ -2,8 +2,7 @@ import React, { Component, PropTypes } from "react";
 
 import FormField from "metabase/components/FormField.jsx";
 import ModalContent from "metabase/components/ModalContent.jsx";
-
-import cx from "classnames";
+import Button from "metabase/components/Button.jsx";
 
 export default class CreateDashboardModal extends Component {
     constructor(props, context) {
@@ -21,7 +20,7 @@ export default class CreateDashboardModal extends Component {
 
     static propTypes = {
         createDashboardFn: PropTypes.func.isRequired,
-        closeFn: PropTypes.func
+        onClose: PropTypes.func
     };
 
     setName(event) {
@@ -72,46 +71,35 @@ export default class CreateDashboardModal extends Component {
 
         var formReady = (name !== null && name !== "");
 
-        var buttonClasses = cx({
-            "Button": true,
-            "Button--primary": formReady
-        });
-
-        var createButton = (
-            <button className={buttonClasses} disabled={!formReady}>
-                Create
-            </button>
-        );
-
         return (
             <ModalContent
                 id="CreateDashboardModal"
-                title="Create Dashboard"
-                closeFn={this.props.closeFn}
+                title="Create dashboard"
+                footer={[
+                    formError,
+                    <Button onClick={this.props.onClose}>Cancel</Button>,
+                    <Button primary={formReady} disabled={!formReady} onClick={this.createNewDash}>Create</Button>
+                ]}
+                onClose={this.props.onClose}
             >
                 <form className="Modal-form" onSubmit={this.createNewDash}>
                     <div className="Form-inputs">
                         <FormField
                             displayName="Name"
                             fieldName="name"
-                            errors={this.state.errors}>
-                            <input className="Form-input
-                            full" name="name" placeholder="What is the name of your dashboard?" value={this.state.name} onChange={this.setName} autoFocus />
+                            errors={this.state.errors}
+                        >
+                            <input className="Form-input full" name="name" placeholder="What is the name of your dashboard?" value={this.state.name} onChange={this.setName} autoFocus />
                         </FormField>
 
                         <FormField
                             displayName="Description"
                             fieldName="description"
-                            errors={this.state.errors}>
+                            errors={this.state.errors}
+                        >
                             <input className="Form-input full" name="description" placeholder="It's optional but oh, so helpful"  value={this.state.description} onChange={this.setDescription} />
                         </FormField>
                     </div>
-
-                    <div className="Form-actions">
-                        {createButton}
-                        <span className="px1">or</span><a className="no-decoration text-brand text-bold" onClick={this.props.closeFn}>Cancel</a>
-                        {formError}
-                    </div>
                 </form>
             </ModalContent>
         );
diff --git a/frontend/src/metabase/components/DeleteModalWithConfirm.jsx b/frontend/src/metabase/components/DeleteModalWithConfirm.jsx
index f3e48b35f54b8519f8bf49440eba71c3357c1cbd..d4380609900deb313087c0b3b025221dff9d78dc 100644
--- a/frontend/src/metabase/components/DeleteModalWithConfirm.jsx
+++ b/frontend/src/metabase/components/DeleteModalWithConfirm.jsx
@@ -36,7 +36,7 @@ export default class DeleteModalWithConfirm extends Component {
         return (
             <ModalContent
                 title={"Delete \"" + objectName + "\"?"}
-                closeFn={this.props.onClose}
+                onClose={this.props.onClose}
             >
             <div className="px4 pb4">
                 <ul>
diff --git a/frontend/src/metabase/components/DeleteQuestionModal.jsx b/frontend/src/metabase/components/DeleteQuestionModal.jsx
index f41ee336ebe872fc6fee4e575dec7fc5fd114ebb..9f9977ba2ae972c05681d032d5adbf020c4478cf 100644
--- a/frontend/src/metabase/components/DeleteQuestionModal.jsx
+++ b/frontend/src/metabase/components/DeleteQuestionModal.jsx
@@ -15,7 +15,7 @@ export default class DeleteQuestionModal extends Component {
     static propTypes = {
         card: PropTypes.object.isRequired,
         deleteCardFn: PropTypes.func.isRequired,
-        closeFn: PropTypes.func
+        onClose: PropTypes.func
     };
 
     async deleteCard() {
@@ -46,7 +46,7 @@ export default class DeleteQuestionModal extends Component {
         return (
             <ModalContent
                 title="Delete Question"
-                closeFn={this.props.closeFn}
+                onClose={this.props.onClose}
             >
                 <div className="Form-inputs mb4">
                     <p>Are you sure you want to do this?</p>
@@ -57,7 +57,7 @@ export default class DeleteQuestionModal extends Component {
 
                 <div className="Form-actions">
                     <button className="Button Button--danger" onClick={() => this.deleteCard()}>Yes</button>
-                    <button className="Button Button--primary ml1" onClick={this.props.closeFn}>No</button>
+                    <button className="Button Button--primary ml1" onClick={this.props.onClose}>No</button>
                     {formError}
                 </div>
             </ModalContent>
diff --git a/frontend/src/metabase/components/DisclosureTriangle.jsx b/frontend/src/metabase/components/DisclosureTriangle.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..25eba51de80fc7e33e620b9218bc1378b0e8f7c8
--- /dev/null
+++ b/frontend/src/metabase/components/DisclosureTriangle.jsx
@@ -0,0 +1,19 @@
+import React, { Component, PropTypes } from "react";
+import { Motion, spring, presets } from "react-motion";
+
+import Icon from "metabase/components/Icon";
+
+const DisclosureTriangle = ({ open }) =>
+    <Motion defaultStyle={{ deg: 0 }} style={{ deg: open ? spring(0, presets.gentle) : spring(-90, presets.gentle) }}>
+        { motionStyle =>
+            <Icon
+                className="ml1 mr1"
+                name="expandarrow"
+                style={{
+                    transform: `rotate(${motionStyle.deg}deg)`
+                }}
+            />
+        }
+    </Motion>
+
+export default DisclosureTriangle;
diff --git a/frontend/src/metabase/components/EmptyState.jsx b/frontend/src/metabase/components/EmptyState.jsx
index a18124e60e03223f1ad35146a7bafea85be14548..a2e16089ca21859f261bd2ee5637c9893b257bdc 100644
--- a/frontend/src/metabase/components/EmptyState.jsx
+++ b/frontend/src/metabase/components/EmptyState.jsx
@@ -4,7 +4,7 @@ import { Link } from "react-router";
 import Icon from "metabase/components/Icon.jsx";
 
 const EmptyState = ({ title, message, icon, image, action, link }) =>
-    <div className="text-centered text-brand-light">
+    <div className="text-centered text-brand-light my2">
         { title &&
             <h2 className="text-brand mb4">{title}</h2>
         }
@@ -15,7 +15,7 @@ const EmptyState = ({ title, message, icon, image, action, link }) =>
             <img src={`${image}.png`} height="250px" alt={message} srcSet={`${image}@2x.png 2x`} />
         }
         <div className="flex justify-center">
-            <h3 className="text-grey-2 mt4" style={{maxWidth: "375px"}}>{message}</h3>
+            <h3 className="text-grey-2 mt4">{message}</h3>
         </div>
         { action &&
             <Link to={link} className="Button Button--primary mt3" target={link.startsWith('http') ? "_blank" : ""}>{action}</Link>
diff --git a/frontend/src/metabase/components/Expandable.jsx b/frontend/src/metabase/components/Expandable.jsx
index 11aa7bae0fc24ebe897bd6950ae648c50d5d964d..f921bde99352074b612009e367948fc4d638999b 100644
--- a/frontend/src/metabase/components/Expandable.jsx
+++ b/frontend/src/metabase/components/Expandable.jsx
@@ -1,6 +1,5 @@
 import React, { Component, PropTypes } from "react";
 
-
 const Expandable = (ComposedComponent) => class extends Component {
     static displayName = "Expandable["+(ComposedComponent.displayName || ComposedComponent.name)+"]";
 
diff --git a/frontend/src/metabase/components/FormField.jsx b/frontend/src/metabase/components/FormField.jsx
index d4bfbff6018ec8e09d9b422b04b8a3558583f0f5..3000b231d68f6f245a8dfec10905c3b07fd3d1e2 100644
--- a/frontend/src/metabase/components/FormField.jsx
+++ b/frontend/src/metabase/components/FormField.jsx
@@ -4,53 +4,45 @@ import cx from "classnames";
 
 export default class FormField extends Component {
     static propTypes = {
-        fieldName: PropTypes.string.isRequired,
+        // redux-form compatible:
+        name: PropTypes.string,
+        error: PropTypes.any,
+        visited: PropTypes.bool,
+        active: PropTypes.bool,
+
         displayName: PropTypes.string.isRequired,
-        showCharm: PropTypes.bool,
+
+        // legacy
+        fieldName: PropTypes.string,
         errors: PropTypes.object
     };
 
-    extractFieldError() {
+    getError() {
+        if (this.props.error && this.props.visited !== false && this.props.active !== true) {
+            return this.props.error;
+        }
+
+        // legacy
         if (this.props.errors &&
             this.props.errors.data.errors &&
             this.props.fieldName in this.props.errors.data.errors) {
             return this.props.errors.data.errors[this.props.fieldName];
-        } else {
-            return null;
         }
     }
 
     render() {
-        var fieldError = this.extractFieldError();
-
-        var fieldClasses = cx({
-            "Form-field": true,
-            "Form--fieldError": (fieldError !== null)
-        });
-
-        var fieldErrorMessage;
-        if (fieldError !== null) {
+        let fieldErrorMessage;
+        let fieldError = this.getError();
+        if (fieldError) {
             fieldErrorMessage = (
                 <span className="text-error mx1">{fieldError}</span>
             );
         }
 
-        var fieldLabel = (
-            <label className="Form-label">{this.props.displayName} {fieldErrorMessage}</label>
-        );
-
-        var formCharm;
-        if (this.props.showCharm) {
-            formCharm = (
-                <span className="Form-charm"></span>
-            );
-        }
-
         return (
-            <div className={fieldClasses}>
-                {fieldLabel}
+            <div className={cx("Form-field", { "Form--fieldError": fieldError })}>
+                <label className="Form-label" htmlFor={this.props.name}>{this.props.displayName} {fieldErrorMessage}</label>
                 {this.props.children}
-                {formCharm}
             </div>
         );
     }
diff --git a/frontend/src/metabase/components/HeaderBar.jsx b/frontend/src/metabase/components/HeaderBar.jsx
index e69c4bdeb30d070b42bd06a820477510d529048f..fe4ead539e062161064cb7a326c1bbae02957f14 100644
--- a/frontend/src/metabase/components/HeaderBar.jsx
+++ b/frontend/src/metabase/components/HeaderBar.jsx
@@ -3,6 +3,7 @@ import React, { Component, PropTypes } from "react";
 import Input from "metabase/components/Input.jsx";
 import TitleAndDescription from "metabase/components/TitleAndDescription.jsx";
 
+import cx from "classnames";
 
 export default class Header extends Component {
 
@@ -13,7 +14,7 @@ export default class Header extends Component {
     };
 
     render() {
-        const { isEditing, name, description, breadcrumb, buttons, className } = this.props;
+        const { isEditing, name, description, breadcrumb, buttons, className, badge } = this.props;
 
         let titleAndDescription;
         if (isEditing) {
@@ -27,7 +28,7 @@ export default class Header extends Component {
             if (name && description) {
                 titleAndDescription = (
                     <TitleAndDescription
-                        title={name} 
+                        title={name}
                         description={description}
                     />
                 );
@@ -41,8 +42,11 @@ export default class Header extends Component {
         }
 
         return (
-            <div className={"QueryBuilder-section flex align-center " + className}>
-                <div className="Entity py1">
+            <div className={cx("QueryBuilder-section flex align-center", className)}>
+                <div className={cx("Entity py1 relative", { "pt2": badge })}>
+                    { badge &&
+                        <div className="absolute top left">{badge}</div>
+                    }
                     {titleAndDescription}
                 </div>
 
diff --git a/frontend/src/metabase/components/HeaderWithBack.jsx b/frontend/src/metabase/components/HeaderWithBack.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..6f5761adfc0bf1f90ca26cf8d70ab74d321e8fb6
--- /dev/null
+++ b/frontend/src/metabase/components/HeaderWithBack.jsx
@@ -0,0 +1,27 @@
+import React, { PropTypes } from "react";
+
+import Icon from "metabase/components/Icon";
+import TitleAndDescription from "metabase/components/TitleAndDescription";
+
+const DEFAULT_BACK = () => window.history.back();
+
+const HeaderWithBack = ({ name, description, onBack }) =>
+    <div className="flex align-center">
+        { (onBack || window.history.length > 1) &&
+            <Icon
+                className="cursor-pointer text-brand mr2 flex align-center circle p2 bg-light-blue bg-brand-hover text-white-hover transition-background transition-color"
+                name="backArrow"
+                onClick={onBack || DEFAULT_BACK}
+            />
+        }
+        <TitleAndDescription
+            title={name}
+            description={description}
+        />
+    </div>
+
+HeaderWithBack.propTypes = {
+    name: PropTypes.string.isRequired
+}
+
+export default HeaderWithBack;
diff --git a/frontend/src/metabase/components/HistoryModal.jsx b/frontend/src/metabase/components/HistoryModal.jsx
index cfea4de803b31ff7799dd5f400de0847664ce7e1..82fcb3bf48e2ca99e38dbb6bd71f88fc8144402a 100644
--- a/frontend/src/metabase/components/HistoryModal.jsx
+++ b/frontend/src/metabase/components/HistoryModal.jsx
@@ -73,7 +73,7 @@ export default class HistoryModal extends Component {
         return (
             <ModalContent
                 title="Change History"
-                closeFn={() => this.props.onClose()}
+                onClose={() => this.props.onClose()}
             >
                 <LoadingAndErrorWrapper loading={!revisions} error={this.state.error}>
                 {() =>
diff --git a/frontend/src/metabase/components/Icon.jsx b/frontend/src/metabase/components/Icon.jsx
index d9a4e155bd0d6a52ed4f9b2944b33f4932e22bc8..847e1cc131e6d55aa169f0db8eb9a0dddd9e92f9 100644
--- a/frontend/src/metabase/components/Icon.jsx
+++ b/frontend/src/metabase/components/Icon.jsx
@@ -5,6 +5,9 @@ import RetinaImage from "react-retina-image";
 
 import { loadIcon } from 'metabase/icon_paths';
 
+import Tooltipify from "metabase/hoc/Tooltipify";
+
+@Tooltipify
 export default class Icon extends Component {
     static propTypes = {
       name: PropTypes.string.isRequired,
diff --git a/frontend/src/metabase/components/LabelIcon.jsx b/frontend/src/metabase/components/LabelIcon.jsx
index 72078b7544cdd91ecd3e7eaac3320d7da28ea57c..51ce497119276171807674ab91a8b8cd4e0e589d 100644
--- a/frontend/src/metabase/components/LabelIcon.jsx
+++ b/frontend/src/metabase/components/LabelIcon.jsx
@@ -7,8 +7,10 @@ import Icon from "./Icon.jsx";
 import EmojiIcon from "./EmojiIcon.jsx";
 import cx from "classnames";
 
-const LabelIcon = ({ icon = "", size = 18, className, style }) =>
-    icon.charAt(0) === ":" ?
+const LabelIcon = ({ icon, size = 18, className, style }) =>
+    !icon ?
+        null
+    : icon.charAt(0) === ":" ?
         <EmojiIcon className={cx(S.icon, S.emojiIcon, className)} name={icon} size={size} style={style} />
     : icon.charAt(0) === "#" ?
         <span className={cx(S.icon, S.colorIcon, className)} style={{ backgroundColor: icon, width: size, height: size }}></span>
diff --git a/frontend/src/metabase/components/Modal.jsx b/frontend/src/metabase/components/Modal.jsx
index 9af365a96710492b8c3202fcd2421b5e735a46d6..dfd2ea3bf77f798360f03b3757577e50b7dbed98 100644
--- a/frontend/src/metabase/components/Modal.jsx
+++ b/frontend/src/metabase/components/Modal.jsx
@@ -3,24 +3,50 @@ import ReactDOM from "react-dom";
 import cx from "classnames";
 
 import ReactCSSTransitionGroup from "react-addons-css-transition-group";
+import { Motion, spring } from "react-motion";
 
 import OnClickOutsideWrapper from "./OnClickOutsideWrapper.jsx";
+import ModalContent from "./ModalContent";
 
-export default class Modal extends Component {
+import _ from "underscore";
+
+export const MODAL_CHILD_CONTEXT_TYPES = {
+    fullPageModal: PropTypes.bool,
+    formModal: PropTypes.bool
+};
+
+function getModalContent(props) {
+    if (React.Children.count(props.children) > 1 ||
+        props.title != null || props.footer != null
+    ) {
+        return <ModalContent {..._.omit(props, "className", "style")} />
+    } else {
+        return React.Children.only(props.children);
+    }
+}
+
+export class WindowModal extends Component {
     static propTypes = {
         isOpen: PropTypes.bool
     };
 
     static defaultProps = {
         className: "Modal",
-        backdropClassName: "Modal-backdrop",
-        isOpen: true
+        backdropClassName: "Modal-backdrop"
     };
 
+    static childContextTypes = MODAL_CHILD_CONTEXT_TYPES;
+
+    getChildContext() {
+        return {
+            fullPageModal: false,
+            formModal: !!this.props.form
+        };
+    }
+
     componentWillMount() {
         this._modalElement = document.createElement('span');
         this._modalElement.className = 'ModalContainer';
-        this._modalElement.id = Math.floor((Math.random() * 698754) + 1);
         document.querySelector('body').appendChild(this._modalElement);
     }
 
@@ -46,10 +72,11 @@ export default class Modal extends Component {
     }
 
     _modalComponent() {
+        const className = cx(this.props.className, ...["small", "medium", "wide", "tall"].filter(type => this.props[type]).map(type => `Modal--${type}`))
         return (
             <OnClickOutsideWrapper handleDismissal={this.handleDismissal.bind(this)}>
-                <div className={cx(this.props.className, 'relative bordered bg-white rounded')}>
-                    {this.props.children}
+                <div className={cx(className, 'relative bordered bg-white rounded')}>
+                    {getModalContent(this.props)}
                 </div>
             </OnClickOutsideWrapper>
         );
@@ -70,6 +97,98 @@ export default class Modal extends Component {
     }
 
     render() {
-        return <span />;
+        return null;
     }
 }
+
+export class FullPageModal extends Component {
+    static childContextTypes = MODAL_CHILD_CONTEXT_TYPES;
+
+    getChildContext() {
+        return {
+            fullPageModal: true,
+            formModal: !!this.props.form
+        };
+    }
+
+    componentDidMount() {
+        this._modalElement = document.createElement("div");
+        this._modalElement.className = "Modal--full";
+        document.querySelector('body').appendChild(this._modalElement);
+
+        this.componentDidUpdate();
+
+        // save the scroll position, scroll to the top left, and disable scrolling
+        this._scrollX = window.scrollX;
+        this._scrollY = window.scrollY;
+        window.scrollTo(0,0);
+        document.body.style.overflow = "hidden";
+    }
+
+    componentDidUpdate() {
+        // set the top of the modal to the bottom of the nav
+        let nav = document.body.querySelector(".Nav");
+        if (nav) {
+            this._modalElement.style.top = nav.getBoundingClientRect().bottom + "px";
+        }
+        this._renderModal(true)
+    }
+
+    componentWillUnmount() {
+        this._renderModal(false);
+
+        // restore scroll position and scrolling
+        window.scrollTo(this._scrollX, this._scrollY);
+        document.body.style.overflow = "unset";
+
+        // wait for animations to complete before unmounting
+        setTimeout(() => {
+            ReactDOM.unmountComponentAtNode(this._modalElement);
+            this._modalElement.parentNode.removeChild(this._modalElement);
+        }, 300);
+    }
+
+    _renderModal(open) {
+        ReactDOM.unstable_renderSubtreeIntoContainer(this,
+            <Motion defaultStyle={{ opacity: 0, top: 20 }} style={open ?
+                { opacity: spring(1), top: spring(0) } :
+                { opacity: spring(0), top: spring(20) }
+            }>
+                { motionStyle =>
+                    <div className="full-height relative" style={motionStyle}>
+                    { getModalContent(this.props) }
+                    </div>
+                }
+            </Motion>
+        , this._modalElement);
+    }
+
+    render() {
+        return null;
+    }
+}
+
+export class InlineModal extends Component {
+    render() {
+        return (
+            <div>
+                {this.props.isOpen ? <FullPageModal {...this.props} /> : null}
+            </div>
+        );
+    }
+}
+
+
+const Modal = ({ full, inline, ...props }) =>
+    full ?
+        (props.isOpen ? <FullPageModal {...props} /> : null)
+    : inline ?
+        <InlineModal {...props} />
+    :
+        <WindowModal {...props} />;
+
+Modal.defaultProps = {
+    isOpen: true,
+};
+
+export default Modal;
diff --git a/frontend/src/metabase/components/ModalContent.css b/frontend/src/metabase/components/ModalContent.css
new file mode 100644
index 0000000000000000000000000000000000000000..4a7f4dde6a6bf386081ece957df525835311699e
--- /dev/null
+++ b/frontend/src/metabase/components/ModalContent.css
@@ -0,0 +1,4 @@
+/* remove padding since ModalContent has it's own */
+.ModalContent .Form-inputs {
+  /*padding: 0;*/
+}
diff --git a/frontend/src/metabase/components/ModalContent.jsx b/frontend/src/metabase/components/ModalContent.jsx
index fc0df8dcecbdb6ce2b77dc9d479947f8bff31532..4f68ae3e3d536ee85bea860b8c55cd9eafc1e457 100644
--- a/frontend/src/metabase/components/ModalContent.jsx
+++ b/frontend/src/metabase/components/ModalContent.jsx
@@ -1,31 +1,99 @@
 import React, { Component, PropTypes } from "react";
 
+import { MODAL_CHILD_CONTEXT_TYPES } from "./Modal";
 import Icon from "metabase/components/Icon.jsx";
 
+import cx from "classnames";
+
+import "./ModalContent.css";
+
 export default class ModalContent extends Component {
     static propTypes = {
         id: PropTypes.string,
         title: PropTypes.string,
-        closeFn: PropTypes.func.isRequired
+        onClose: PropTypes.func.isRequired
     };
 
     static defaultProps = {
-        className: "Modal-content NewForm"
     };
 
+    static contextTypes = MODAL_CHILD_CONTEXT_TYPES;
+
     render() {
+        const { title, footer, onClose, children, className } = this.props;
+
+        const { fullPageModal, formModal } = this.context;
         return (
-            <div id={this.props.id} className={this.props.className}>
-                <div className="Modal-header Form-header flex align-center">
-                    <h2 className="flex-full">{this.props.title}</h2>
-                    <a className="text-grey-3 p1" onClick={this.props.closeFn}>
-                        <Icon name='close' size={16}/>
-                    </a>
-                </div>
-                <div className="Modal-body">
-                    {this.props.children}
-                </div>
+            <div
+                id={this.props.id}
+                className={cx("ModalContent NewForm flex-full flex flex-column relative", className, { "full-height": fullPageModal && !formModal })}
+            >
+                { onClose &&
+                    <Icon
+                        className="text-grey-2 text-grey-4-hover cursor-pointer absolute m2 p2 top right"
+                        name="close"
+                        size={fullPageModal ? 24 : 16}
+                        onClick={onClose}
+                    />
+                }
+                { title &&
+                    <ModalHeader>
+                        {title}
+                    </ModalHeader>
+                }
+                <ModalBody>
+                    {children}
+                </ModalBody>
+                { footer &&
+                    <ModalFooter>
+                        {footer}
+                    </ModalFooter>
+                }
             </div>
         );
     }
 }
+
+const FORM_WIDTH = 500 + 32 * 2; // includes padding
+
+export const ModalHeader = ({ children }, { fullPageModal, formModal }) =>
+    <div className={cx("ModalHeader flex-no-shrink px4 py4 full")}>
+        <h2 className={cx("text-bold", { "text-centered": fullPageModal })}>{children}</h2>
+    </div>
+
+ModalHeader.contextTypes = MODAL_CHILD_CONTEXT_TYPES;
+
+export const ModalBody = ({ children }, { fullPageModal, formModal }) =>
+    <div
+        className={cx("ModalBody", { "px4": formModal, "flex flex-full": !formModal })}
+    >
+        <div
+            className="flex-full ml-auto mr-auto flex flex-column"
+            style={{ maxWidth: (formModal && fullPageModal) ? FORM_WIDTH : undefined }}
+        >
+            {children}
+        </div>
+    </div>
+
+ModalBody.contextTypes = MODAL_CHILD_CONTEXT_TYPES;
+
+export const ModalFooter = ({ children }, { fullPageModal, formModal }) =>
+    <div
+        className={cx("ModalFooter flex-no-shrink px4", fullPageModal ? "py4" : "py2", {
+            "border-top": !fullPageModal || (fullPageModal && !formModal),
+        })}
+    >
+        <div
+            className="flex-full ml-auto mr-auto flex"
+            style={{ maxWidth: (formModal && fullPageModal) ? FORM_WIDTH : undefined }}
+        >
+            <div className="flex-full" />
+            { Array.isArray(children) ?
+                children.map((child, index) => <span key={index} className="ml2">{child}</span>)
+            :
+                children
+            }
+        </div>
+    </div>
+
+ModalFooter.contextTypes = MODAL_CHILD_CONTEXT_TYPES;
diff --git a/frontend/src/metabase/components/Popover.jsx b/frontend/src/metabase/components/Popover.jsx
index fd31dbff49690b021147c959680ca1014c596ba8..164a806f712b8dce822b7bf788c95676291c8585 100644
--- a/frontend/src/metabase/components/Popover.jsx
+++ b/frontend/src/metabase/components/Popover.jsx
@@ -86,7 +86,7 @@ export default class Popover extends Component {
 
     _popoverComponent() {
         return (
-            <OnClickOutsideWrapper handleDismissal={this.handleDismissal}>
+            <OnClickOutsideWrapper handleDismissal={this.handleDismissal} dismissOnEscape={this.props.dismissOnEscape} dismissOnClickOutside={this.props.dismissOnClickOutside}>
                 <div id={this.props.id} className={cx("PopoverBody", { "PopoverBody--withArrow": this.props.hasArrow }, this.props.className)}>
                     { typeof this.props.children === "function" ?
                         this.props.children()
diff --git a/frontend/src/metabase/components/QuestionSavedModal.jsx b/frontend/src/metabase/components/QuestionSavedModal.jsx
index bedb1d4bd0649a00b50f6a25ba365b9c484325ac..6623904d06014358400fdffe83b2d3862acc4e00 100644
--- a/frontend/src/metabase/components/QuestionSavedModal.jsx
+++ b/frontend/src/metabase/components/QuestionSavedModal.jsx
@@ -6,7 +6,7 @@ import ModalContent from "metabase/components/ModalContent.jsx";
 export default class QuestionSavedModal extends Component {
     static propTypes = {
         addToDashboardFn: PropTypes.func.isRequired,
-        closeFn: PropTypes.func.isRequired
+        onClose: PropTypes.func.isRequired
     };
 
     render() {
@@ -14,12 +14,12 @@ export default class QuestionSavedModal extends Component {
             <ModalContent
                 id="QuestionSavedModal"
                 title="Saved! Add this to a dashboard?"
-                closeFn={this.props.closeFn}
+                onClose={this.props.onClose}
                 className="Modal-content Modal-content--small NewForm"
             >
                 <div className="Form-inputs mb4">
                     <button className="Button Button--primary" onClick={this.props.addToDashboardFn}>Yes please!</button>
-                    <button className="Button ml3" onClick={this.props.closeFn}>Not now</button>
+                    <button className="Button ml3" onClick={this.props.onClose}>Not now</button>
                 </div>
             </ModalContent>
         );
diff --git a/frontend/src/metabase/components/SaveStatus.jsx b/frontend/src/metabase/components/SaveStatus.jsx
index 4376878a34d408d0c55b6943ca61cedd7d851635..7768239adcdc0e40dd026f839a37ed9c146cf171 100644
--- a/frontend/src/metabase/components/SaveStatus.jsx
+++ b/frontend/src/metabase/components/SaveStatus.jsx
@@ -46,7 +46,7 @@ export default class SaveStatus extends Component {
                 </div>
             )
         } else {
-            return <span />;
+            return null;
         }
     }
 }
diff --git a/frontend/src/metabase/components/Select.jsx b/frontend/src/metabase/components/Select.jsx
index 720ac18b2309033e780dc36db3d6ca450c889773..3c63a985b726f7f1dc9822934aca8f79c7d26db5 100644
--- a/frontend/src/metabase/components/Select.jsx
+++ b/frontend/src/metabase/components/Select.jsx
@@ -119,23 +119,29 @@ class BrowserSelect extends Component {
 
 export class Option extends Component {
     static propTypes = {
-        children: PropTypes.any,
-        selected: PropTypes.bool,
-        disabled: PropTypes.bool,
-        onClick: PropTypes.func
+        children:   PropTypes.any,
+        selected:   PropTypes.bool,
+        disabled:   PropTypes.bool,
+        onClick:    PropTypes.func,
+        icon:       PropTypes.string,
+        iconColor:  PropTypes.string,
+        iconSize:   PropTypes.number,
     };
 
     render() {
-        const { children, selected, disabled, onClick } = this.props;
+        const { children, selected, disabled, icon, iconColor, iconSize, onClick } = this.props;
         return (
             <div
                 onClick={onClick}
-                className={cx("ColumnarSelector-row flex no-decoration", {
+                className={cx("ColumnarSelector-row flex align-center cursor-pointer no-decoration relative", {
                     "ColumnarSelector-row--selected": selected,
                     "disabled": disabled
                 })}
             >
-                <Icon name="check"  size={14}/>
+                <Icon name="check" size={14} />
+                { icon &&
+                    <Icon name={icon} style={{ position: "absolute", color: iconColor, visibility: !selected ? "visible" : "hidden" }} size={iconSize} />
+                }
                 {children}
             </div>
         );
@@ -147,7 +153,7 @@ class LegacySelect extends Component {
         value: PropTypes.any,
         values: PropTypes.array,
         options: PropTypes.array.isRequired,
-        disabledOptionIds: PropTypes.array, 
+        disabledOptionIds: PropTypes.array,
         placeholder: PropTypes.string,
         emptyPlaceholder: PropTypes.string,
         onChange: PropTypes.func,
@@ -176,10 +182,10 @@ class LegacySelect extends Component {
     render() {
         const { className, value, values, onChange, options, disabledOptionIds, optionNameFn, optionValueFn, placeholder, emptyPlaceholder, isInitiallyOpen, disabled } = this.props;
 
-        var selectedName = value ? 
-            optionNameFn(value) : 
-            options && options.length > 0 ? 
-                placeholder : 
+        var selectedName = value ?
+            optionNameFn(value) :
+            options && options.length > 0 ?
+                placeholder :
                 emptyPlaceholder;
 
         var triggerElement = (
diff --git a/frontend/src/metabase/components/TitleAndDescription.jsx b/frontend/src/metabase/components/TitleAndDescription.jsx
index b6bd5b9f68345b6293d3519d3be25f0845cee888..81dee583aad5a0617bdc0d3ece93aee999ba9728 100644
--- a/frontend/src/metabase/components/TitleAndDescription.jsx
+++ b/frontend/src/metabase/components/TitleAndDescription.jsx
@@ -9,7 +9,7 @@ const TitleAndDescription = ({ title, description }) =>
         <h2 className="mr1">{title}</h2>
         { description &&
             <Tooltip tooltip={description} maxWidth={'22em'}>
-                <Icon name='info'/>
+                <Icon name='info' style={{ marginTop: 3 }}/>
             </Tooltip>
         }
     </div>;
diff --git a/frontend/src/metabase/components/SaveQuestionModal.css b/frontend/src/metabase/containers/SaveQuestionModal.css
similarity index 100%
rename from frontend/src/metabase/components/SaveQuestionModal.css
rename to frontend/src/metabase/containers/SaveQuestionModal.css
diff --git a/frontend/src/metabase/components/SaveQuestionModal.jsx b/frontend/src/metabase/containers/SaveQuestionModal.jsx
similarity index 51%
rename from frontend/src/metabase/components/SaveQuestionModal.jsx
rename to frontend/src/metabase/containers/SaveQuestionModal.jsx
index adcca466084d6215bd827a8b412ecd8ab4f9e80b..4ed8a3174d90f48c56e31f391206736845d471c1 100644
--- a/frontend/src/metabase/components/SaveQuestionModal.jsx
+++ b/frontend/src/metabase/containers/SaveQuestionModal.jsx
@@ -5,15 +5,15 @@ import ReactCSSTransitionGroup from "react-addons-css-transition-group";
 import FormField from "metabase/components/FormField.jsx";
 import ModalContent from "metabase/components/ModalContent.jsx";
 import Radio from "metabase/components/Radio.jsx";
+import Select, { Option } from "metabase/components/Select.jsx";
+import Button from "metabase/components/Button";
+import CollectionList from "metabase/questions/containers/CollectionList";
 
 import Query from "metabase/lib/query";
 import { cancelable } from "metabase/lib/promise";
 
-import cx from "classnames";
-
 import "./SaveQuestionModal.css";
 
-
 export default class SaveQuestionModal extends Component {
 
     constructor(props, context) {
@@ -27,6 +27,7 @@ export default class SaveQuestionModal extends Component {
             details: {
                 name: props.card.name || isStructured ? Query.generateQueryDescription(props.tableMetadata, props.card.dataset_query.query) : "",
                 description: props.card.description || null,
+                collection_id: props.card.collection_id || null,
                 saveType: props.originalCard ? "overwrite" : "create"
             }
         };
@@ -38,7 +39,7 @@ export default class SaveQuestionModal extends Component {
         tableMetadata: PropTypes.object, // can't be required, sometimes null
         createFn: PropTypes.func.isRequired,
         saveFn: PropTypes.func.isRequired,
-        closeFn: PropTypes.func.isRequired
+        onClose: PropTypes.func.isRequired
     }
 
     componentDidMount() {
@@ -74,9 +75,11 @@ export default class SaveQuestionModal extends Component {
         this.setState({ details: { ...this.state.details, [fieldName]: fieldValue ? fieldValue : null }});
     }
 
-    async formSubmitted(e) {
+    formSubmitted = async (e) => {
         try {
-            e.preventDefault();
+            if (e) {
+                e.preventDefault();
+            }
 
             let { details } = this.state;
             let { card, originalCard, addToDashboard, createFn, saveFn } = this.props;
@@ -89,7 +92,8 @@ export default class SaveQuestionModal extends Component {
                 // since description is optional, it can be null, so check for a description before trimming it
                 description: details.saveType === "overwrite" ?
                     originalCard.description :
-                    details.description ? details.description.trim() : null
+                    details.description ? details.description.trim() : null,
+                collection_id: details.collection_id
             };
 
             if (details.saveType === "create") {
@@ -100,7 +104,7 @@ export default class SaveQuestionModal extends Component {
             }
 
             await this.requestPromise;
-            this.props.closeFn();
+            this.props.onClose();
         } catch (error) {
             if (error && !error.isCanceled) {
                 this.setState({ error: error });
@@ -145,7 +149,7 @@ export default class SaveQuestionModal extends Component {
                         onChange={(value) => this.onChange("saveType", value)}
                         options={[
                             { name: `Replace original question, "${this.props.originalCard.name}"`, value: "overwrite" },
-                            { name: "Save as new question", value: "replace" },
+                            { name: "Save as new question", value: "create" },
                         ]}
                         isVertical
                     />
@@ -153,51 +157,90 @@ export default class SaveQuestionModal extends Component {
             );
         }
 
-        let title = this.props.addToDashboard ? "First, Save Your Question" : "Save Question";
+        let title = this.props.addToDashboard ? "First, save your question" : "Save question";
 
         return (
             <ModalContent
                 id="SaveQuestionModal"
                 title={title}
-                closeFn={this.props.closeFn}
+                footer={[
+                        formError,
+                        <Button onClick={this.props.onClose}>
+                            Cancel
+                        </Button>,
+                        <Button primary={this.state.valid} disabled={!this.state.valid} onClick={this.formSubmitted}>
+                            Save
+                        </Button>
+                ]}
+                onClose={this.props.onClose}
             >
-                <form className="flex flex-column flex-full" onSubmit={(e) => this.formSubmitted(e)}>
-                    <div className="Form-inputs">
-                        {saveOrUpdate}
-                        <ReactCSSTransitionGroup
-                            transitionName="saveQuestionModalFields"
-                            transitionEnterTimeout={500}
-                            transitionLeaveTimeout={500}
-                        >
-                            { details.saveType === "create" &&
-                                <div key="saveQuestionModalFields" className="saveQuestionModalFields">
+                <form className="Form-inputs" onSubmit={this.formSubmitted}>
+                    {saveOrUpdate}
+                    <ReactCSSTransitionGroup
+                        transitionName="saveQuestionModalFields"
+                        transitionEnterTimeout={500}
+                        transitionLeaveTimeout={500}
+                    >
+                        { details.saveType === "create" &&
+                            <div key="saveQuestionModalFields" className="saveQuestionModalFields">
+                                <FormField
+                                    displayName="Name"
+                                    fieldName="name"
+                                    errors={this.state.errors}
+                                >
+                                    <input
+                                        className="Form-input full"
+                                        name="name" placeholder="What is the name of your card?"
+                                        value={this.state.details.name}
+                                        onChange={(e) => this.onChange("name", e.target.value)}
+                                        autoFocus
+                                    />
+                                </FormField>
+                                <FormField
+                                    displayName="Description"
+                                    fieldName="description"
+                                    errors={this.state.errors}
+                                >
+                                    <textarea
+                                        className="Form-input full"
+                                        name="description"
+                                        placeholder="It's optional but oh, so helpful"
+                                        value={this.state.details.description}
+                                        onChange={(e) => this.onChange("description", e.target.value)}
+                                    />
+                                </FormField>
+                                <CollectionList writable>
+                                { (collections) => collections.length > 0 &&
                                     <FormField
-                                        key="name"
-                                        displayName="Name"
-                                        fieldName="name"
-                                        errors={this.state.errors}>
-                                        <input className="Form-input full" name="name" placeholder="What is the name of your card?" value={this.state.details.name} onChange={(e) => this.onChange("name", e.target.value)} autoFocus/>
+                                        displayName="Which collection should this go in?"
+                                        fieldName="collection_id"
+                                        errors={this.state.errors}
+                                    >
+                                        <Select
+                                            className="block"
+                                            value={this.state.details.collection_id}
+                                            onChange={e => this.onChange("collection_id", e.target.value)}
+                                        >
+                                            {[{ name: "None", id: null }]
+                                            .concat(collections)
+                                            .map((collection, index) =>
+                                                <Option
+                                                    key={index}
+                                                    value={collection.id}
+                                                    icon={collection.id != null && "collection"}
+                                                    iconColor={collection.color}
+                                                    iconSize={18}
+                                                >
+                                                    {collection.name}
+                                                </Option>
+                                            )}
+                                        </Select>
                                     </FormField>
-                                    <FormField
-                                        key="description"
-                                        displayName="Description"
-                                        fieldName="description"
-                                        errors={this.state.errors}>
-                                        <textarea className="Form-input full" name="description" placeholder="It's optional but oh, so helpful" value={this.state.details.description} onChange={(e) => this.onChange("description", e.target.value)} />
-                                    </FormField>
-                                </div>
-                            }
-                        </ReactCSSTransitionGroup>
-                    </div>
-
-                    <div className="Form-actions">
-                        <button className={cx("Button", { "Button--primary": this.state.valid })} disabled={!this.state.valid}>
-                            Save
-                        </button>
-                        <span className="px1">or</span>
-                        <a className="no-decoration text-brand text-bold" onClick={this.props.closeFn}>Cancel</a>
-                        {formError}
-                    </div>
+                                }
+                                </CollectionList>
+                            </div>
+                        }
+                    </ReactCSSTransitionGroup>
                 </form>
             </ModalContent>
         );
diff --git a/frontend/src/metabase/questions/containers/UndoListing.css b/frontend/src/metabase/containers/UndoListing.css
similarity index 79%
rename from frontend/src/metabase/questions/containers/UndoListing.css
rename to frontend/src/metabase/containers/UndoListing.css
index f32245dd586438f7e49a7d3c524a1f3c92dbbc6b..3be2dae68c297a134267fd45feac0c99259beeba 100644
--- a/frontend/src/metabase/questions/containers/UndoListing.css
+++ b/frontend/src/metabase/containers/UndoListing.css
@@ -1,5 +1,3 @@
-@import '../Questions.css';
-
 :local(.listing) {
     composes: m2 from "style";
     composes: fixed left bottom from "style";
@@ -8,23 +6,21 @@
 
 :local(.undo) {
     composes: mt2 p2 from "style";
-    composes: bordered from "style";
-    composes: rounded from "style";
-    composes: shadowed from "style";
-    composes: relative from "style";
+    composes: bordered rounded shadowed from "style";
     composes: bg-white from "style";
+    composes: relative from "style";
+    composes: flex align-center from "style";
     width: 320px;
 }
 
 :local(.actions) {
-    composes: flex align-center from "style";
-    composes: float-right from "style";
+    composes: flex align-center flex-align-right from "style";
 }
 
 :local(.undoButton) {
     composes: mx2 from "style";
     composes: text-uppercase text-bold from "style";
-    color: var(--blue-color);
+    color: var(--brand-color);
 }
 :local(.dismissButton) {
     composes: cursor-pointer from "style";
diff --git a/frontend/src/metabase/questions/containers/UndoListing.jsx b/frontend/src/metabase/containers/UndoListing.jsx
similarity index 78%
rename from frontend/src/metabase/questions/containers/UndoListing.jsx
rename to frontend/src/metabase/containers/UndoListing.jsx
index 1f4c82f26b242a6535ae7b97fc099be75a3c3806..8d1e7785a39cb51b33b74d790bdb01d20421a02a 100644
--- a/frontend/src/metabase/questions/containers/UndoListing.jsx
+++ b/frontend/src/metabase/containers/UndoListing.jsx
@@ -4,8 +4,8 @@ import { connect } from "react-redux";
 
 import S from "./UndoListing.css";
 
-import { dismissUndo, performUndo } from "../undo";
-import { getUndos } from "../selectors";
+import { dismissUndo, performUndo } from "metabase/redux/undo";
+import { getUndos } from "metabase/selectors/undo";
 
 import Icon from "metabase/components/Icon";
 import BodyComponent from "metabase/components/BodyComponent";
@@ -14,7 +14,7 @@ import ReactCSSTransitionGroup from "react-addons-css-transition-group";
 
 const mapStateToProps = (state, props) => {
   return {
-      undos: getUndos(state)
+      undos: getUndos(state, props)
   }
 }
 
@@ -43,11 +43,14 @@ export default class UndoListing extends Component {
                 >
                 { undos.map(undo =>
                     <li key={undo._domId} className={S.undo}>
-                        <span className={S.message}>{typeof undo.message === "function" ? undo.message(undo) : undo.message}</span>
-                        <span className={S.actions}>
+                        <div className={S.message}>
+                            {typeof undo.message === "function" ? undo.message(undo) : undo.message}
+                        </div>
+
+                        <div className={S.actions}>
                             <a className={S.undoButton} onClick={() => performUndo(undo.id)}>Undo</a>
                             <Icon className={S.dismissButton} name="close" onClick={() => dismissUndo(undo.id)} />
-                        </span>
+                        </div>
                     </li>
                 )}
                 </ReactCSSTransitionGroup>
diff --git a/frontend/src/metabase/css/admin.css b/frontend/src/metabase/css/admin.css
index f8a2d1f063383cc8af7ca01a9d0c0c918e077de6..9ebfe602072bf1051d0c31cd6cb25789fa5be7d8 100644
--- a/frontend/src/metabase/css/admin.css
+++ b/frontend/src/metabase/css/admin.css
@@ -1,5 +1,6 @@
 :root {
-    --admin-nav-bg-color: #6F7A8B;
+    --admin-nav-bg-color: #8091AB;
+    --admin-nav-bg-color-tint: #9AA7BC;
     --admin-nav-item-text-color: rgba(255, 255, 255, 0.63);
     --admin-nav-item-text-active-color: #fff;
     --page-header-padding: 2.375rem;
@@ -36,11 +37,11 @@
 
 .AdminNav .NavDropdown.open .NavDropdown-button,
 .AdminNav .NavDropdown .NavDropdown-content-layer {
-    background-color: #8993A1;
+    background-color: var(--admin-nav-bg-color-tint);
 }
 
 .AdminNav .Dropdown-item:hover {
-    background-color: #6F7A8B;
+    background-color: var(--admin-nav-bg-color);
 }
 
 /* utility to get a simple common hover state for admin items */
diff --git a/frontend/src/metabase/css/components/buttons.css b/frontend/src/metabase/css/components/buttons.css
index bc391150739ed2b4baa3238b327d985c0183668d..45275e8d3d145ba35f8f80625d07fb6245963a84 100644
--- a/frontend/src/metabase/css/components/buttons.css
+++ b/frontend/src/metabase/css/components/buttons.css
@@ -45,7 +45,7 @@
 }
 
 .Button--small {
-    padding: 0.4rem 0.75rem;
+    padding: 0.45rem 1rem;
     font-size: 0.6rem;
 }
 
diff --git a/frontend/src/metabase/css/components/header.css b/frontend/src/metabase/css/components/header.css
index e3fb14b9504a985ae834ddaa690a09cf838878c8..d835233c7bc189a992b830b026604c8cc428ef13 100644
--- a/frontend/src/metabase/css/components/header.css
+++ b/frontend/src/metabase/css/components/header.css
@@ -39,7 +39,7 @@
 }
 
 .EditHeader.EditHeader--admin {
-    background-color: #8C95A2;
+    background-color: var(--admin-nav-bg-color-tint);
 }
 
 .EditHeader-title {
@@ -52,28 +52,29 @@
 }
 
 .EditHeader .Button {
-    color: var(--brand-color);
+    color: white;
     border: none;
-    text-transform: uppercase;
-    font-size: 0.75rem;
-    background-color: rgba(255,255,255,0.5);
-    font-weight: normal;
+    font-size: 1em;
+    text-transform: capitalize;
+    background-color: rgba(255,255,255,0.1);
     margin-left: 0.75em;
 }
 
 .EditHeader .Button--primary {
     background-color: white;
+    color: var(--brand-color);
 }
 
-.EditHeader .Button:hover {
-    color: white;
-    background-color: var(--brand-color);
+.EditHeader.EditHeader--admin .Button--primary {
+  background-color: white;
+  color: var(--70-percent-black);
 }
 
-.EditHeader.EditHeader--admin .Button {
+.EditHeader .Button:hover {
     color: white;
+    background-color: var(--brand-color);
 }
 
-.EditHeader.EditHeader--admin .Button.Button--primary {
-    color: var(--brand-color);
+.EditHeader.EditHeader--admin .Button:hover {
+  background-color: var(--admin-nav-bg-color);
 }
diff --git a/frontend/src/metabase/css/components/list.css b/frontend/src/metabase/css/components/list.css
new file mode 100644
index 0000000000000000000000000000000000000000..be2a8a8b70b2ac67a1adfe2cfeae2ec351f89d1f
--- /dev/null
+++ b/frontend/src/metabase/css/components/list.css
@@ -0,0 +1,69 @@
+
+.List {
+  padding: var(--padding-1);
+}
+
+.List-section-header .Icon,
+.List-item .List-item-arrow .Icon {
+  color: var(--default-font-color);
+}
+
+.List-item .Icon {
+    color: var(--slate-light-color);
+}
+
+.List-section-header {
+    color: var(--default-font-color);
+    border: 2px solid transparent; /* so that spacing matches .List-item */
+}
+
+/* these crazy rules are needed to get currentColor to propagate to the right elements in the right states */
+.List-section .List-section-header:hover,
+.List-section .List-section-header:hover .Icon,
+.List-section .List-section-header:hover .List-section-title,
+.List-section--open .List-section-header,
+.List-section--open .List-section-header .List-section-icon .Icon {
+    color: currentColor;
+}
+
+.List-section--open .List-section-header .List-section-title {
+    color: var(--default-font-color);
+}
+
+.List-section-title {
+  word-wrap: break-word;
+  max-width: 230px;
+}
+
+.List-item {
+    display: flex;
+    border-radius: 6px;
+    border: 2px solid transparent;
+    margin-top: 2px;
+    margin-bottom: 2px;
+}
+
+.List-item--disabled .List-item-title {
+    color: var(--grey-3);
+}
+
+.List-item:not(.List-item--disabled):hover,
+.List-item--selected {
+    background-color: currentColor;
+    border-color: rgba(0,0,0,0.2);
+    /*color: white;*/
+}
+
+.List-item-title {
+    color: var(--default-font-color);
+}
+
+.List-item:not(.List-item--disabled):hover .List-item-title,
+.List-item--selected .List-item-title {
+    color: white;
+}
+
+.List-item:not(.List-item--disabled):hover .Icon,
+.List-item--selected .Icon {
+    color: white !important;
+}
diff --git a/frontend/src/metabase/css/components/modal.css b/frontend/src/metabase/css/components/modal.css
index c9c22c20f8fde6bc1575e70a190fc163ec3fae12..ba111df10102426b27652f3c19f14f690d0e4c79 100644
--- a/frontend/src/metabase/css/components/modal.css
+++ b/frontend/src/metabase/css/components/modal.css
@@ -18,6 +18,16 @@
   min-height: 85%;
 }
 
+.Modal--full {
+  background-color: white;
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 3;
+}
+
 .Modal-backdrop {
     background-color: rgba(255, 255, 255, 0.6);
 }
diff --git a/frontend/src/metabase/css/components/popover.css b/frontend/src/metabase/css/components/popover.css
index 3dd2d01def7279f683e0a5048b7fedbe7e09bb75..c41b6c2bc26d0e0d12debde58c80e380aa168831 100644
--- a/frontend/src/metabase/css/components/popover.css
+++ b/frontend/src/metabase/css/components/popover.css
@@ -11,7 +11,6 @@
 
 .PopoverBody {
 	pointer-events: auto;
-	position: relative;
 	min-width: 1em; /* ewwwwwwww */
 	border: 1px solid #ddd;
 	box-shadow: 0 1px 7px rgba(0, 0, 0, .18);
@@ -19,6 +18,7 @@
 	border-radius: 4px;
 	display: flex;
 	flex-direction: column;
+	overflow: hidden;
 }
 
 .PopoverBody.PopoverBody--tooltip {
diff --git a/frontend/src/metabase/css/core/base.css b/frontend/src/metabase/css/core/base.css
index 3356cd98e8162f30a018991aadfd3105d0d8f0f5..3cc81ea67e3e70b1d3cc7e332bb85857b418388f 100644
--- a/frontend/src/metabase/css/core/base.css
+++ b/frontend/src/metabase/css/core/base.css
@@ -57,10 +57,16 @@ textarea {
   opacity: 0.4;
 }
 
+.faded, :local(.faded) {
+  opacity: 0.4;
+}
+
 .MB-lightBG {
   background-color: #f9fbfc;
 }
 
+.circle { border-radius: 99px; }
+
 .undefined {
     border: 1px solid red !important;
 }
diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css
index cec62039e13fd9e43867bbbcf32ff41c7460777d..03e5920b1ea2b04e07b8ca18625f4805425bd030 100644
--- a/frontend/src/metabase/css/core/colors.css
+++ b/frontend/src/metabase/css/core/colors.css
@@ -128,7 +128,7 @@
 .bg-green { background-color: var(--green-color); }
 
 /* alt */
-.bg-alt { background-color: var(--alt-color); }
+.bg-alt, .bg-alt-hover:hover { background-color: var(--alt-color); }
 
 /* grey */
 .text-grey-1, :local(.text-grey-1),
@@ -143,7 +143,8 @@
 .text-grey-4,
 .text-grey-4-hover:hover { color: var(--grey-4) }
 
-.bg-grey-0 { background-color: var(--base-grey) }
+.bg-grey-0,
+.bg-grey-0-hover:hover { background-color: var(--base-grey) }
 .bg-grey-1 { background-color: var(--grey-1) }
 .bg-grey-2 { background-color: var(--grey-2) }
 .bg-grey-3 { background-color: var(--grey-3) }
@@ -173,7 +174,12 @@
 
 .bg-light-blue { background-color: #F5FAFC; }
 
+.bg-light-blue-hover:hover {
+  background-color: #E4F0FA;
+}
+
 .text-light-blue,
 .text-light-blue-hover:hover {
   color: #CFE4F5
 }
+.text-slate { color: #606E7B; }
diff --git a/frontend/src/metabase/css/core/flex.css b/frontend/src/metabase/css/core/flex.css
index 9c555afb311b1fe88c299be16eb2b4c0cd55f836..9ad2d5853c9fea87147af5128d3264e0e8de72e1 100644
--- a/frontend/src/metabase/css/core/flex.css
+++ b/frontend/src/metabase/css/core/flex.css
@@ -40,7 +40,7 @@
     align-items: flex-end;
 }
 
-.align-self-end {
+.align-self-end, :local(.align-self-end) {
     align-self: flex-end;
 }
 
diff --git a/frontend/src/metabase/css/core/hover.css b/frontend/src/metabase/css/core/hover.css
new file mode 100644
index 0000000000000000000000000000000000000000..20dfa4219af9c970e282fd641cb2199b31a095ca
--- /dev/null
+++ b/frontend/src/metabase/css/core/hover.css
@@ -0,0 +1,17 @@
+/*
+  display
+  hide and show a child element using display
+*/
+.hover-parent.hover--display .hover-child,
+.hover-parent:hover.hover--display .hover-child--hidden { display: none; }
+
+.hover-parent:hover.hover--display .hover-child { display: block; }
+
+/*
+  visibility
+  hide and show a child element using visibility
+*/
+.hover-parent.hover--visibility .hover-child,
+.hover-parent:hover.hover--visibility .hover-child--hidden { visibility: hidden; }
+
+.hover-parent:hover.hover--visibility .hover-child { visibility: visible; }
diff --git a/frontend/src/metabase/css/core/index.css b/frontend/src/metabase/css/core/index.css
index c5e206f7ced5a10d557b8594ba20553d9750fe8e..fa94b8c9a1a50c6f20e7d24f6c8bb0af3cd3d341 100644
--- a/frontend/src/metabase/css/core/index.css
+++ b/frontend/src/metabase/css/core/index.css
@@ -11,6 +11,7 @@
 @import './grid.css';
 @import './headings.css';
 @import './hide.css';
+@import './hover.css';
 @import './inputs.css';
 @import './layout.css';
 @import './link.css';
diff --git a/frontend/src/metabase/css/core/inputs.css b/frontend/src/metabase/css/core/inputs.css
index 0dd52730685c54a5f5f160833ed52da14ad8ba60..d727a683237d9cb7c91f75c047cf87ae3baef15c 100644
--- a/frontend/src/metabase/css/core/inputs.css
+++ b/frontend/src/metabase/css/core/inputs.css
@@ -5,10 +5,12 @@
 }
 
 .input, :local(.input) {
+  color: var(--dark-color);
+  font-size: 1.12em;
+  padding: 0.75rem 0.75rem;
   border: 1px solid var(--input-border-color);
-  padding: 0.8rem 1rem;
-  transition: border .3s linear;
   border-radius: var(--input-border-radius);
+  transition: border .3s linear;
 }
 
 .input--small {
@@ -20,6 +22,7 @@
   outline: none;
   border: 1px solid var(--input-border-active-color);
   transition: border .3s linear;
+  color: #222;
 }
 
 .input--borderless,
diff --git a/frontend/src/metabase/css/core/text.css b/frontend/src/metabase/css/core/text.css
index 13541831fad980919ae70951b506d8ce9f5ee718..900539540b8d2ae245eadbe07ea9935b47dd0ba3 100644
--- a/frontend/src/metabase/css/core/text.css
+++ b/frontend/src/metabase/css/core/text.css
@@ -1,5 +1,6 @@
 :root {
   --body-text-color: #8E9BA9;
+  --70-percent-black: #444444;
 }
 
 /* center */
diff --git a/frontend/src/metabase/css/index.css b/frontend/src/metabase/css/index.css
index b65cc8ac418a170b29c684eec73f9f0891cab827..9d47a330eceaebd9a53e9b92dc9b3f6f85ba7e61 100644
--- a/frontend/src/metabase/css/index.css
+++ b/frontend/src/metabase/css/index.css
@@ -5,6 +5,7 @@
 @import './components/form.css';
 @import './components/header.css';
 @import './components/icons.css';
+@import './components/list.css';
 @import './components/modal.css';
 @import './components/popover.css';
 @import './components/select.css';
diff --git a/frontend/src/metabase/css/query_builder.css b/frontend/src/metabase/css/query_builder.css
index da248e50891a699cfd8c20d8e5132ec0bffc726c..abaaf76bf3dd0ed09fae1881067076a89f8bd4b3 100644
--- a/frontend/src/metabase/css/query_builder.css
+++ b/frontend/src/metabase/css/query_builder.css
@@ -357,6 +357,9 @@
 .GuiBuilder-filtered-by {
     border-right: 1px solid transparent;
 }
+.GuiBuilder-view {
+    border-right: 1px solid #e0e0e0;
+}
 .GuiBuilder-sort-limit {
     border-left: 1px solid #e0e0e0;
 }
@@ -377,7 +380,7 @@
 .GuiBuilder-section {
     position: relative;
     min-height: 55px;
-    min-width: 120px;
+    min-width: 100px;
 }
 
 .GuiBuilder-section-label {
@@ -598,52 +601,9 @@
     min-width: 25em;
 }
 
-.List {
-  padding: var(--padding-1);
-}
-
-.List-section-header .Icon,
-.List-item .List-item-arrow .Icon {
-  color: var(--default-font-color);
-}
-
-.List-item .Icon {
-    color: var(--slate-light-color);
-}
-
-.List-section-header {
-    color: var(--default-font-color);
-    border: 2px solid transparent; /* so that spacing matches .List-item */
-}
-
-/* these crazy rules are needed to get currentColor to propagate to the right elements in the right states */
-.List-section .List-section-header:hover,
-.List-section .List-section-header:hover .Icon,
-.List-section .List-section-header:hover .List-section-title,
-.List-section--open .List-section-header,
-.List-section--open .List-section-header .List-section-icon .Icon {
-    color: currentColor;
-}
-
-.List-section--open .List-section-header .List-section-title {
-    color: var(--default-font-color);
-}
-
-.List-section-title {
-  word-wrap: break-word;
-  max-width: 230px;
-}
-
-.List-item {
+.FieldList-grouping-trigger {
     display: flex;
-    border-radius: 6px;
-    border: 2px solid transparent;
-    margin-top: 2px;
-    margin-bottom: 2px;
-}
-
-.List-item--disabled .List-item-title {
-    color: var(--grey-3);
+    visibility: hidden;
 }
 
 .List-item--segment .Icon,
@@ -656,32 +616,6 @@
   color: var(--brand-color);
 }
 
-.List-item:not(.List-item--disabled):hover,
-.List-item--selected {
-    background-color: currentColor;
-    border-color: rgba(0,0,0,0.2);
-    /*color: white;*/
-}
-
-.List-item-title {
-    color: var(--default-font-color);
-}
-
-.List-item:not(.List-item--disabled):hover .List-item-title,
-.List-item--selected .List-item-title {
-    color: white;
-}
-
-.List-item:not(.List-item--disabled):hover .Icon,
-.List-item--selected .Icon {
-    color: white;
-}
-
-.FieldList-grouping-trigger {
-    display: flex;
-    visibility: hidden;
-}
-
 .List-item:not(.List-item--disabled):hover .FieldList-grouping-trigger,
 .List-item--selected .FieldList-grouping-trigger {
     visibility: visible;
diff --git a/frontend/src/metabase/dashboard/components/AddToDashSelectQuestionModal.jsx b/frontend/src/metabase/dashboard/components/AddToDashSelectQuestionModal.jsx
index faf680d8fe4ec37072c16f97f48b69bdd0d7e1d0..e357fc8e76b808dcae0e5301e27b2a402b9bd6b0 100644
--- a/frontend/src/metabase/dashboard/components/AddToDashSelectQuestionModal.jsx
+++ b/frontend/src/metabase/dashboard/components/AddToDashSelectQuestionModal.jsx
@@ -1,9 +1,7 @@
 import React, { Component, PropTypes } from "react";
 
 import MetabaseAnalytics from "metabase/lib/analytics";
-import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx";
-import ModalContent from "metabase/components/ModalContent.jsx";
-import SortableItemList from "metabase/components/SortableItemList.jsx";
+import AddToDashboard from "metabase/questions/containers/AddToDashboard.jsx";
 
 
 export default class AddToDashSelectQuestionModal extends Component {
@@ -43,25 +41,10 @@ export default class AddToDashSelectQuestionModal extends Component {
     }
 
     render() {
-        var { error } = this.state;
-        if (this.props.cards && this.props.cards.length === 0) {
-            error = { message: "No cards have been saved." };
-        }
         return (
-            <ModalContent
-                title="Add Question to Dashboard"
-                closeFn={this.props.onClose}
-            >
-                <LoadingAndErrorWrapper loading={!this.props.cards} error={error} >
-                {() =>
-                    <SortableItemList
-                        items={this.props.cards}
-                        onClickItemFn={(card) => this.onAdd(card)}
-                        showIcons={true}
-                    />
-                }
-                </LoadingAndErrorWrapper>
-            </ModalContent>
-        );
+            <AddToDashboard
+                onAdd={(card) => this.onAdd(card)}
+            />
+        )
     }
 }
diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx
index 779062f34e71e5e147e74b33283f3cebe556b780..891b9052b235022b2ba4dbdcc2559bf48a682540 100644
--- a/frontend/src/metabase/dashboard/components/DashCard.jsx
+++ b/frontend/src/metabase/dashboard/components/DashCard.jsx
@@ -151,7 +151,7 @@ const DashCardActionButtons = ({ series, onRemove, onAddSeries, onReplaceAllVisu
 
 const ChartSettingsButton = ({ series, onReplaceAllVisualizationSettings }) =>
     <ModalWithTrigger
-        className="Modal Modal--wide Modal--tall"
+        wide tall
         triggerElement={<Icon name="gear" />}
         triggerClasses="text-grey-2 text-grey-4-hover cursor-pointer mr1 flex align-center flex-no-shrink"
     >
diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx
index 6705b4ecb8b8339bb502b959f5b251aebe62c951..a0cbfb5d50bf180d24bfcc7fc26f41e2d0601ce7 100644
--- a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx
+++ b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx
@@ -97,22 +97,13 @@ export default class DashboardHeader extends Component {
 
     getEditingButtons() {
         return [
-            <ActionButton
-                key="save"
-                actionFn={() => this.onSave()}
-                className="Button Button--small Button--primary text-uppercase"
-                normalText="Save"
-                activeText="Saving…"
-                failedText="Save failed"
-                successText="Saved"
-            />,
-            <a data-metabase-event="Dashboard;Cancel Edits" key="cancel" className="Button Button--small text-uppercase" onClick={() => this.onCancel()}>
+            <a data-metabase-event="Dashboard;Cancel Edits" key="cancel" className="Button Button--small" onClick={() => this.onCancel()}>
                 Cancel
             </a>,
             <ModalWithTrigger
                 key="delete"
                 ref="deleteDashboardModal"
-                triggerClasses="Button Button--small text-uppercase"
+                triggerClasses="Button Button--small"
                 triggerElement="Delete"
             >
                 <DeleteDashboardModal
@@ -120,7 +111,16 @@ export default class DashboardHeader extends Component {
                     onClose={() => this.refs.deleteDashboardModal.toggle()}
                     onDelete={() => this.onDelete()}
                 />
-            </ModalWithTrigger>
+            </ModalWithTrigger>,
+            <ActionButton
+                key="save"
+                actionFn={() => this.onSave()}
+                className="Button Button--small Button--primary"
+                normalText="Save"
+                activeText="Saving…"
+                failedText="Save failed"
+                successText="Saved"
+            />
         ];
     }
 
@@ -200,6 +200,7 @@ export default class DashboardHeader extends Component {
         if (!isFullscreen && canEdit) {
             buttons.push(
                 <ModalWithTrigger
+                    full
                     key="add"
                     ref="addQuestionModal"
                     triggerElement={
diff --git a/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx b/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx
index 63c4e5b2af13f9dbc505be2ddb028e5d3330a0b1..653449a0899930a454017e99479d208f00af4ded 100644
--- a/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx
+++ b/frontend/src/metabase/dashboard/components/DeleteDashboardModal.jsx
@@ -46,7 +46,7 @@ export default class DeleteDashboardModal extends Component {
         return (
             <ModalContent
                 title="Delete Dashboard"
-                closeFn={this.props.onClose}
+                onClose={this.props.onClose}
             >
                 <div className="Form-inputs mb4">
                     <p>Are you sure you want to do this?</p>
diff --git a/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx b/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx
index 2643055d99af0ac65e2106b8087664938beeeb34..e6d17b5fa6122f15c26c49701d9315898adf9418 100644
--- a/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx
+++ b/frontend/src/metabase/dashboard/components/RemoveFromDashboardModal.jsx
@@ -61,7 +61,7 @@ export default class RemoveFromDashboardModal extends Component {
         return (
             <ModalContent
                 title="Remove from Dashboard"
-                closeFn={() => this.props.onClose()}
+                onClose={() => this.props.onClose()}
             >
                 <div className="flex-full px4 pb3 text-grey-4">
                     <p>Are you sure you want to do this?</p>
diff --git a/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx b/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx
index b5a23be3048c78237d968d08117cfabf7ea41db0..83c57fdea34bd76a9b4affd9becb75434c02f862 100644
--- a/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx
+++ b/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx
@@ -1,9 +1,105 @@
 import React, { Component, PropTypes } from "react";
 
-import RelativeDatePicker from "metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx";
-
+import cx from "classnames";
 import _ from "underscore";
 
+const SHORTCUTS = [
+        { name: "Today",        operator: ["=", "<", ">"], values: [["relative_datetime", "current"]]},
+        { name: "Yesterday",    operator: ["=", "<", ">"], values: [["relative_datetime", -1, "day"]]},
+        { name: "Past 7 days",  operator: "TIME_INTERVAL", values: [-7, "day"]},
+        { name: "Past 30 days", operator: "TIME_INTERVAL", values: [-30, "day"]}
+];
+
+const RELATIVE_SHORTCUTS = {
+        "Last": [
+            { name: "Week",  operator: "TIME_INTERVAL", values: ["last", "week"]},
+            { name: "Month", operator: "TIME_INTERVAL", values: ["last", "month"]},
+            { name: "Year",  operator: "TIME_INTERVAL", values: ["last", "year"]}
+        ],
+        "This": [
+            { name: "Week",  operator: "TIME_INTERVAL", values: ["current", "week"]},
+            { name: "Month", operator: "TIME_INTERVAL", values: ["current", "month"]},
+            { name: "Year",  operator: "TIME_INTERVAL", values: ["current", "year"]}
+        ]
+};
+
+class PredefinedRelativeDatePicker extends Component {
+    constructor(props, context) {
+        super(props, context);
+
+        _.bindAll(this, "isSelectedShortcut", "onSetShortcut");
+    }
+
+    static propTypes = {
+        filter: PropTypes.array.isRequired,
+        onFilterChange: PropTypes.func.isRequired
+    };
+
+    isSelectedShortcut(shortcut) {
+        let { filter } = this.props;
+        return (
+            (Array.isArray(shortcut.operator) ? _.contains(shortcut.operator, filter[0]): filter[0] === shortcut.operator ) &&
+            _.isEqual(filter.slice(2), shortcut.values)
+        );
+    }
+
+    onSetShortcut(shortcut) {
+        let { filter } = this.props;
+        let operator;
+        if (Array.isArray(shortcut.operator)) {
+            if (_.contains(shortcut.operator, filter[0])) {
+                operator = filter[0];
+            } else {
+                operator = shortcut.operator[0];
+            }
+        } else {
+            operator = shortcut.operator;
+        }
+        this.props.onFilterChange([operator, filter[1], ...shortcut.values])
+    }
+
+    render() {
+        return (
+            <div className="p1 pt2">
+                <section>
+                    { SHORTCUTS.map((s, index) =>
+                        <span key={index} className={cx("inline-block half pb1", { "pr1": index % 2 === 0 })}>
+                            <button
+                                key={index}
+                                className={cx("Button Button-normal Button--medium text-normal text-centered full", { "Button--purple": this.isSelectedShortcut(s) })}
+                                onClick={() => this.onSetShortcut(s)}
+                            >
+                                {s.name}
+                            </button>
+                        </span>
+                    )}
+                </section>
+                {Object.keys(RELATIVE_SHORTCUTS).map(sectionName =>
+                    <section key={sectionName}>
+                        <div style={{}} className="border-bottom text-uppercase flex layout-centered mb2">
+                            <h6 style={{"position": "relative", "backgroundColor": "white", "top": "6px" }} className="px2">
+                                {sectionName}
+                            </h6>
+                        </div>
+                        <div className="flex">
+                            { RELATIVE_SHORTCUTS[sectionName].map((s, index) =>
+                                <button
+                                    key={index}
+                                    data-ui-tag={"relative-date-shortcut-" + sectionName.toLowerCase() + "-" + s.name.toLowerCase()}
+                                    className={cx("Button Button-normal Button--medium flex-full mb1", { "Button--purple": this.isSelectedShortcut(s), "mr1": index !== RELATIVE_SHORTCUTS[sectionName].length - 1 })}
+                                    onClick={() => this.onSetShortcut(s)}
+                                >
+                                    {s.name}
+                                </button>
+                            )}
+                        </div>
+                    </section>
+                )}
+            </div>
+        );
+    }
+}
+
 // HACK: easiest way to get working with RelativeDatePicker
 const FILTERS = {
     "today": {
@@ -63,7 +159,7 @@ export default class DateRelativeWidget extends Component {
         const { value, setValue, onClose } = this.props;
         return (
             <div className="px1" style={{ maxWidth: 300 }}>
-                <RelativeDatePicker
+                <PredefinedRelativeDatePicker
                     filter={FILTERS[value] ? FILTERS[value].mapping : [null, null]}
                     onFilterChange={(filter) => {
                         setValue(_.findKey(FILTERS, (f) => _.isEqual(f.mapping, filter)));
diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js
index 30c3eb229bb0c0b6c21428ad2a74ff25803480d1..88444d18e749af12880e0c977c69885d261574a6 100644
--- a/frontend/src/metabase/dashboard/dashboard.js
+++ b/frontend/src/metabase/dashboard/dashboard.js
@@ -85,6 +85,7 @@ export const markNewCardSeen = createAction(MARK_NEW_CARD_SEEN);
 export const setDashboardAttributes = createAction(SET_DASHBOARD_ATTRIBUTES);
 export const setDashCardAttributes = createAction(SET_DASHCARD_ATTRIBUTES);
 
+// TODO: consolidate with questions reducer
 export const fetchCards = createThunkAction(FETCH_CARDS, function(filterMode = "all") {
     return async function(dispatch, getState) {
         let cards = await CardApi.list({ f: filterMode });
@@ -411,6 +412,7 @@ const isEditing = handleActions({
     [SET_EDITING_DASHBOARD]: { next: (state, { payload }) => payload }
 }, false);
 
+// TODO: consolidate with questions reducer
 const cards = handleActions({
     [FETCH_CARDS]: { next: (state, { payload }) => ({ ...payload.entities.card }) }
 }, {});
diff --git a/frontend/src/metabase/debug.js b/frontend/src/metabase/debug.js
new file mode 100644
index 0000000000000000000000000000000000000000..a719d7a29fe0a5a0adcfa1b120f1ef03f7a78453
--- /dev/null
+++ b/frontend/src/metabase/debug.js
@@ -0,0 +1,43 @@
+import React from "react";
+import { Route } from "react-router";
+
+import Icon from "metabase/components/Icon.jsx";
+
+const SIZES = [12, 16, 32];
+
+const IconsApp = () =>
+    <table className="Table m4" style={{ width: "inherit" }}>
+        <thead>
+            <tr>
+                <th>Name</th>
+                {SIZES.map(size =>
+                    <th>{size}px</th>
+                )}
+            </tr>
+        </thead>
+        <tbody>
+        { Object.keys(require("metabase/icon_paths").ICON_PATHS).map(name =>
+            <tr>
+                <td>{name}</td>
+                {SIZES.map(size =>
+                    <td><Icon name={name} size={size} /></td>
+                )}
+            </tr>
+        )}
+        </tbody>
+    </table>
+
+// const IconsApp = () =>
+//     <div className="flex flex-wrap">
+//         { Object.keys(require("metabase/icon_paths").ICON_PATHS).map(name =>
+//             <div className="flex flex-column layout-centered mr1 mb1 bordered rounded p2">
+//                 <td><Icon name={name} className="bordered"/></td>
+//                 <div>{name}</div>
+//             </div>
+//         )}
+//     </div>
+
+export const getRoutes = () =>
+    <Route path="/_debug">
+        <Route path="icons" component={IconsApp} />
+    </Route>
diff --git a/frontend/src/metabase/hoc/Tooltipify.jsx b/frontend/src/metabase/hoc/Tooltipify.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..e2c8a2e45f0820cb544e25fc66e7569e47adc0ba
--- /dev/null
+++ b/frontend/src/metabase/hoc/Tooltipify.jsx
@@ -0,0 +1,17 @@
+import React, { Component, PropTypes } from "react";
+
+import Tooltip from "metabase/components/Tooltip";
+
+const Tooltipify = (ComposedComponent) => class extends Component {
+    static displayName = "Tooltipify["+(ComposedComponent.displayName || ComposedComponent.name)+"]";
+    render() {
+        const { tooltip, ...props } = this.props;
+        if (tooltip) {
+            return <Tooltip tooltip={tooltip}><ComposedComponent {...props} /></Tooltip>;
+        } else {
+            return <ComposedComponent {...props} />;
+        }
+    }
+}
+
+export default Tooltipify;
diff --git a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx
index 5722a7e1b4a5498f36f6729f3df68f2591062047..5ee55acb2574c600f08811ca7467fb59f406d54e 100644
--- a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx
+++ b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx
@@ -11,7 +11,7 @@ export default class NewUserOnboardingModal extends Component {
     }
 
     static propTypes = {
-        closeFn: PropTypes.func.isRequired,
+        onClose: PropTypes.func.isRequired,
         user: PropTypes.object.isRequired
     }
 
@@ -29,7 +29,7 @@ export default class NewUserOnboardingModal extends Component {
     }
 
     closeModal() {
-        this.props.closeFn();
+        this.props.onClose();
     }
 
     renderStep() {
diff --git a/frontend/src/metabase/home/containers/HomepageApp.jsx b/frontend/src/metabase/home/containers/HomepageApp.jsx
index ecba49e21c96688a698769bac44e0ba56286a9f2..1644539b7c056abbebd2c43b7a06f0a82f7fe275 100644
--- a/frontend/src/metabase/home/containers/HomepageApp.jsx
+++ b/frontend/src/metabase/home/containers/HomepageApp.jsx
@@ -71,7 +71,7 @@ export default class HomepageApp extends Component {
                     <Modal>
                         <NewUserOnboardingModal
                             user={user}
-                            closeFn={() => (this.completeOnboarding())}
+                            onClose={() => (this.completeOnboarding())}
                         />
                     </Modal>
                 : null }
diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js
index ff2fb912a348bb93a2555bfe3c801af9c1fead18..3fd20af20aa70d51c2ab899c9b350b636fb090a1 100644
--- a/frontend/src/metabase/icon_paths.js
+++ b/frontend/src/metabase/icon_paths.js
@@ -10,20 +10,15 @@ export var ICON_PATHS = {
     add: 'M19,13 L19,2 L14,2 L14,13 L2,13 L2,18 L14,18 L14,30 L19,30 L19,18 L30,18 L30,13 L19,13 Z',
     addtodash: 'M21,23 L16,23 L16,27 L21,27 L21,32 L25,32 L25,27 L30,27 L30,23 L25,23 L25,18 L21,18 L21,23 Z M4,28 L4,8 L0,8 L0,29.5 L0,32 L12,32 L12,28 L4,28 Z M32,4 L32,14 L28,14 L28,8 L0,8 L0,0 L32,0 L32,4 Z',
     all: 'M30.595 13.536c1.85.755 1.879 2.05.053 2.9l-11.377 5.287c-1.82.846-4.763.858-6.583.022L1.344 16.532c-1.815-.835-1.785-2.131.05-2.89l1.637-.677 8.977 4.125c2.194 1.009 5.74.994 7.934-.026l9.022-4.193 1.63.665zm-1.63 7.684l1.63.666c1.85.755 1.879 2.05.053 2.898l-11.377 5.288c-1.82.847-4.763.859-6.583.022L1.344 24.881c-1.815-.834-1.785-2.131.05-2.89l1.637-.677 8.977 4.126c2.194 1.008 5.74.993 7.934-.026l9.022-4.194zM12.686 1.576c1.843-.762 4.834-.77 6.687-.013l11.22 4.578c1.85.755 1.88 2.05.054 2.899l-11.377 5.288c-1.82.846-4.763.858-6.583.022L1.344 9.136c-1.815-.834-1.785-2.13.05-2.89l11.293-4.67z',
-    archive: 'M2.783 12.8h26.434V29H2.783V12.8zm6.956 3.4h12.522v2.6H9.739v-2.6zM0 4h32v6.4H0V4z',
-    area: {
-        path: 'M25.4980562,23.9977382 L26.0040287,23.9999997 L26.0040283,22.4903505 L26.0040283,14 L26.0040287,12 L25.3213548,13.2692765 C25.3213548,13.2692765 22.6224921,15.7906709 21.2730607,17.0513681 C21.1953121,17.1240042 15.841225,18.0149981 15.841225,18.0149981 L15.5173319,18.0717346 L15.2903187,18.3096229 L10.5815987,23.2439142 L9.978413,23.9239006 L11.3005782,23.9342813 L25.4980562,23.9977382 L11.3050484,23.9342913 L16.0137684,19 L21.7224883,18 L26.0040283,14 L26.0040283,23.4903505 C26.0040283,23.7718221 25.7731425,23.9989679 25.4980562,23.9977382 Z M7,23.9342913 L14,16 L21,14 L25.6441509,9.35958767 C25.8429057,9.16099288 26.0040283,9.22974944 26.0040283,9.49379817 L26.0040283,13 L26.0040283,24 L7,23.9342913 Z',
-        attrs: { scale: 2 }
-    },
-    bar: {
-        path: 'M9,20 L12,20 L12,24 L9,24 L9,20 Z M14,14 L17,14 L17,24 L14,24 L14,14 Z M19,9 L22,9 L22,24 L19,24 L19,9 Z',
-        attrs: { scale: 2 }
+    archive: {
+        path: 'M32 10V8H0v23h32V10zm-5 2H5v15h22V12zM3 2h26v3H3V2zm11 12v5h-4l6 7 6-7h-4v-5h-4z',
+        attrs: { fillRule: "evenodd" }
     },
+    area: 'M31.154 28.846l.852.004V8.64l-1.15 2.138-6.818 6.37c-.13.122-9.148 1.622-9.148 1.622l-.545.096-.383.4-7.93 8.31-1.016 1.146 2.227.017 23.91.107L7.25 28.74l7.93-8.31 9.615-1.684 7.211-6.737v15.984a.855.855 0 0 1-.852.854zM0 28.74l11.79-13.362 11.788-3.369 8.077-8.07c.194-.193.351-.128.351.15V28.85L0 28.74z',
+    backArrow: 'M11.7416687,19.0096 L18.8461178,26.4181004 L14.2696969,30.568 L0.38960831,16.093881 L0,15.6875985 L0.49145276,15.241949 L14.6347557,1 L19.136,5.22693467 L11.3214393,13.096 L32,13.096 L32,19.0096 L11.7416687,19.0096 Z',
+    bar: 'M2 23.467h6.4V32H2v-8.533zm10.667-12.8h6.4V32h-6.4V10.667zM23.333 0h6.4v32h-6.4V0z',
     beaker: 'M4.31736354,31.1631075 C3.93810558,30.6054137 3.89343681,29.6635358 4.20559962,29.0817181 L11.806982,14.9140486 L11.8069821,10.5816524 L10.7015144,10.4653256 C10.0309495,10.394763 9.48734928,9.78799739 9.48734928,9.12166999 L9.48734928,7.34972895 C9.48734928,6.67821106 10.0368737,6.13383825 10.7172248,6.13383825 L21.8462005,6.13383825 C22.525442,6.13383825 23.0760761,6.68340155 23.0760761,7.34972895 L23.0760761,9.12166999 C23.0760761,9.79318788 22.5250158,10.3375607 21.856025,10.3375607 L20.9787023,10.3375607 L20.9787024,14.9281806 L28.77277,29.0827118 C29.0983515,29.6739888 29.0709073,30.6193105 28.7174156,31.1846409 L28.852457,30.9686726 C28.4963041,31.538259 27.6541076,32 26.9865771,32 L6.10749779,32 C5.43315365,32 4.58248747,31.5529687 4.19978245,30.9902061 L4.31736354,31.1631075 Z M15.5771418,17.6040443 C16.5170398,17.6040443 17.2789777,16.8377777 17.2789777,15.89254 C17.2789777,14.9473023 16.5170398,14.1810358 15.5771418,14.1810358 C14.6372438,14.1810358 13.8753059,14.9473023 13.8753059,15.89254 C13.8753059,16.8377777 14.6372438,17.6040443 15.5771418,17.6040443 Z M16.5496195,12.8974079 C17.8587633,12.8974079 18.9200339,11.830108 18.9200339,10.5135268 C18.9200339,9.1969457 17.8587633,8.1296458 16.5496195,8.1296458 C15.2404758,8.1296458 14.1792052,9.1969457 14.1792052,10.5135268 C14.1792052,11.830108 15.2404758,12.8974079 16.5496195,12.8974079 Z M5.71098553,30.2209651 L10.9595331,20.5151267 C10.9595331,20.5151267 12.6834557,21.2672852 14.3734184,21.2672852 C16.0633811,21.2672852 16.8198616,19.2872624 17.588452,18.6901539 C18.3570425,18.0930453 19.9467191,17.1113296 19.9467191,17.1113296 L27.0506095,30.1110325 L5.71098553,30.2209651 Z M13.6608671,4.37817079 C14.4114211,4.37817079 15.0198654,3.78121712 15.0198654,3.04483745 C15.0198654,2.30845779 14.4114211,1.71150412 13.6608671,1.71150412 C12.9103132,1.71150412 12.3018689,2.30845779 12.3018689,3.04483745 C12.3018689,3.78121712 12.9103132,4.37817079 13.6608671,4.37817079 Z M17.9214578,2.45333328 C18.6119674,2.45333328 19.1717361,1.90413592 19.1717361,1.22666664 C19.1717361,0.549197362 18.6119674,0 17.9214578,0 C17.2309481,0 16.6711794,0.549197362 16.6711794,1.22666664 C16.6711794,1.90413592 17.2309481,2.45333328 17.9214578,2.45333328 Z',
-    bubble: {
-        path: 'M21.443455,16.6275712 C22.5442864,15.6139893 23.2340002,14.1607199 23.2340002,12.5463593 C23.2340002,9.48318964 20.7508106,7 17.6876409,7 C14.6244712,7 12.1412816,9.48318964 12.1412816,12.5463593 C12.1412816,15.3777798 14.2629452,17.7136592 17.0031769,18.050902 C16.5949606,18.6535787 16.3565147,19.380623 16.3565147,20.1633594 C16.3565147,22.2463148 18.0450836,23.9348837 20.128039,23.9348837 C22.2109944,23.9348837 23.8995633,22.2463148 23.8995633,20.1633594 C23.8995633,18.5430605 22.8778027,17.1614065 21.443455,16.6275712 Z M13.6203108,23.4172235 C14.437156,23.4172235 15.0993399,22.7550396 15.0993399,21.9381944 C15.0993399,21.1213491 14.437156,20.4591652 13.6203108,20.4591652 C12.8034655,20.4591652 12.1412816,21.1213491 12.1412816,21.9381944 C12.1412816,22.7550396 12.8034655,23.4172235 13.6203108,23.4172235 Z M10.4034224,20.0894079 C11.7307959,20.0894079 12.8068447,19.0133591 12.8068447,17.6859856 C12.8068447,16.3586121 11.7307959,15.2825632 10.4034224,15.2825632 C9.07604884,15.2825632 8,16.3586121 8,17.6859856 C8,19.0133591 9.07604884,20.0894079 10.4034224,20.0894079 Z',
-        attrs: { scale: 2 }
-    },
+    bubble: 'M14.9318357,10.2330954 C16.1019037,9.15576448 16.8349969,7.61109204 16.8349969,5.89519651 C16.8349969,2.63936938 14.1956275,0 10.9398004,0 C7.68397325,0 5.04460387,2.63936938 5.04460387,5.89519651 C5.04460387,8.90469864 7.29970908,11.3874927 10.2122871,11.7459463 C9.77839613,12.3865283 9.52495321,13.1593 9.52495321,13.9912664 C9.52495321,16.2052288 11.3197244,18 13.5336868,18 C15.7476493,18 17.5424205,16.2052288 17.5424205,13.9912664 C17.5424205,12.2690591 16.4563964,10.8005062 14.9318357,10.2330954 Z M6.61665627,17.4497817 C7.48487684,17.4497817 8.18870867,16.7459498 8.18870867,15.8777293 C8.18870867,15.0095087 7.48487684,14.3056769 6.61665627,14.3056769 C5.7484357,14.3056769 5.04460387,15.0095087 5.04460387,15.8777293 C5.04460387,16.7459498 5.7484357,17.4497817 6.61665627,17.4497817 Z M3.1974423,13.9126638 C4.60830072,13.9126638 5.75202745,12.768937 5.75202745,11.3580786 C5.75202745,9.94722018 4.60830072,8.80349345 3.1974423,8.80349345 C1.78658387,8.80349345 0.642857143,9.94722018 0.642857143,11.3580786 C0.642857143,12.768937 1.78658387,13.9126638 3.1974423,13.9126638 Z',
     cards: 'M16.5,11 C16.1340991,11 15.7865579,10.9213927 15.4733425,10.7801443 L7.35245972,21.8211652 C7.7548404,22.264891 8,22.8538155 8,23.5 C8,24.8807119 6.88071187,26 5.5,26 C4.11928813,26 3,24.8807119 3,23.5 C3,22.1192881 4.11928813,21 5.5,21 C5.87370843,21 6.22826528,21.0819977 6.5466604,21.2289829 L14.6623495,10.1950233 C14.2511829,9.74948188 14,9.15407439 14,8.5 C14,7.11928813 15.1192881,6 16.5,6 C17.8807119,6 19,7.11928813 19,8.5 C19,8.96980737 18.8704088,9.4093471 18.6450228,9.78482291 L25.0405495,15.4699905 C25.4512188,15.1742245 25.9552632,15 26.5,15 C27.8807119,15 29,16.1192881 29,17.5 C29,18.8807119 27.8807119,20 26.5,20 C25.1192881,20 24,18.8807119 24,17.5 C24,17.0256697 24.1320984,16.5821926 24.3615134,16.2043506 L17.9697647,10.5225413 C17.5572341,10.8228405 17.0493059,11 16.5,11 Z M5.5,25 C6.32842712,25 7,24.3284271 7,23.5 C7,22.6715729 6.32842712,22 5.5,22 C4.67157288,22 4,22.6715729 4,23.5 C4,24.3284271 4.67157288,25 5.5,25 Z M26.5,19 C27.3284271,19 28,18.3284271 28,17.5 C28,16.6715729 27.3284271,16 26.5,16 C25.6715729,16 25,16.6715729 25,17.5 C25,18.3284271 25.6715729,19 26.5,19 Z M16.5,10 C17.3284271,10 18,9.32842712 18,8.5 C18,7.67157288 17.3284271,7 16.5,7 C15.6715729,7 15,7.67157288 15,8.5 C15,9.32842712 15.6715729,10 16.5,10 Z',
     calendar: {
         path: 'M21,2 L21,0 L18,0 L18,2 L6,2 L6,0 L3,0 L3,2 L2.99109042,2 C1.34177063,2 0,3.34314575 0,5 L0,6.99502651 L0,20.009947 C0,22.2157067 1.78640758,24 3.99005301,24 L20.009947,24 C22.2157067,24 24,22.2135924 24,20.009947 L24,6.99502651 L24,5 C24,3.34651712 22.6608432,2 21.0089096,2 L21,2 L21,2 Z M22,8 L22,20.009947 C22,21.1099173 21.1102431,22 20.009947,22 L3.99005301,22 C2.89008272,22 2,21.1102431 2,20.009947 L2,8 L22,8 L22,8 Z M6,12 L10,12 L10,16 L6,16 L6,12 Z',
@@ -41,6 +36,7 @@ export var ICON_PATHS = {
         attrs: { viewBox: '0 0 16 15' }
     },
     close: 'M4 8 L8 4 L16 12 L24 4 L28 8 L20 16 L28 24 L24 28 L16 20 L8 28 L4 24 L12 16 z ',
+    collection: 'M16.5695046,2.82779686 L15.5639388,2.83217072 L30.4703127,11.5065092 L30.4818076,9.80229623 L15.5754337,18.2115855 L16.5436335,18.2077098 L1.65289961,9.96407638 L1.67877073,11.6677911 L16.5695046,2.82779686 Z M0.691634577,11.6826271 L15.5823685,19.9262606 C15.8836872,20.0930731 16.2506087,20.0916044 16.5505684,19.9223849 L31.4569423,11.5130957 C32.1196316,11.1392458 32.1260238,10.1915465 31.4684372,9.80888276 L16.5620632,1.1345443 C16.2511162,0.953597567 15.8658421,0.955273376 15.5564974,1.13891816 L0.665763463,9.97891239 C0.0118284022,10.3671258 0.0262104889,11.3142428 0.691634577,11.6826271 Z M15.5699489,25.798061 L16.0547338,26.0652615 L16.536759,25.7931643 L31.4991818,17.3470627 C31.973977,17.0790467 32.1404815,16.4788587 31.8710802,16.0065052 C31.6016788,15.5341517 30.9983884,15.3685033 30.5235933,15.6365193 L15.5611705,24.0826209 L16.5279806,24.0777242 L1.46763754,15.7768642 C0.99012406,15.5136715 0.388560187,15.6854222 0.124007019,16.16048 C-0.14054615,16.6355379 0.0320922897,17.2340083 0.509605765,17.497201 L15.5699489,25.798061 Z M15.5699489,31.7327994 L16.0547338,32 L16.536759,31.7279028 L31.4991818,23.2818011 C31.973977,23.0137852 32.1404815,22.4135972 31.8710802,21.9412437 C31.6016788,21.4688901 30.9983884,21.3032418 30.5235933,21.5712578 L15.5611705,30.0173594 L16.5279806,30.0124627 L1.46763754,21.7116027 C0.99012406,21.44841 0.388560187,21.6201606 0.124007019,22.0952185 C-0.14054615,22.5702764 0.0320922897,23.1687467 0.509605765,23.4319394 L15.5699489,31.7327994 Z',
     countrymap: {
         path: 'M19.4375,6.97396734 L27,8.34417492 L27,25.5316749 L18.7837192,23.3765689 L11.875,25.3366259 L11.875,25.3366259 L11.875,11.0941749 L11.1875,11.0941749 L11.1875,25.5316749 L5,24.1566749 L5,7.65667492 L11.1875,9.03167492 L18.75,6.90135976 L18.75,22.0941749 L19.4375,22.0941749 L19.4375,6.97396734 Z',
         attrs: { scale: 2 }
@@ -74,6 +70,7 @@ export var ICON_PATHS = {
     emojisymbols: 'M15.915,6.8426232 L13.8540602,4.78168347 C10.8078936,1.73551684 5.86858639,1.73495294 2.82246208,4.78107725 C-0.217463984,7.82100332 -0.223390824,12.7662163 2.82306829,15.8126754 L15.6919527,28.6815598 L15.915,28.4585125 L16.1380473,28.6815598 L29.0069316,15.8126754 C32.0533907,12.7662163 32.0474639,7.82100332 29.0075378,4.78107725 C25.9614135,1.73495294 21.0221063,1.73551684 17.9759397,4.78168347 L15.915,6.8426232 Z',
     emojitravel: 'M21.5273864,25.0150018 L21.5273864,1.91741484 C21.5273864,0.0857778237 20.049926,-1.38499821 18.2273864,-1.38499821 C16.4085552,-1.38499821 14.9273864,0.0935424793 14.9273864,1.91741484 L14.9273864,25.0150018 L11.6273864,28.3150018 L24.8273864,28.3150018 L21.5273864,25.0150018 Z M1.72738636,18.4150018 L14.9273864,5.21500179 L14.9273864,15.7750018 L1.72738636,23.6950018 L1.72738636,18.4150018 Z M34.7273864,18.4150018 L21.5273864,5.21500179 L21.5273864,15.7750018 L34.7273864,23.6950018 L34.7273864,18.4150018 Z',
     expand: 'M29,13.6720571 L29,8.26132482e-16 L15.3279429,8.64083276e-16 L20.3471502,5.01920738 L15.0015892,10.3647684 L18.6368207,14 L23.9823818,8.65443894 L29,13.6720571 Z M0.00158917013,15.3279429 L0.00158917013,29 L13.6736463,29 L8.65443894,23.9807926 L14,18.6352316 L10.3647684,15 L5.01920738,20.3455611 L0.00158917013,15.3279429 Z',
+    expandarrow: 'M16.429 28.429L.429 5.57h32z',
     explore: 'M16.4796545,16.298957 L16.4802727,23.0580389 L16.4802727,23.0580389 C17.3528782,23.2731238 18,24.0609902 18,25 C18,26.1045695 17.1045695,27 16,27 C14.8954305,27 14,26.1045695 14,25 C14,24.0751922 14.6276951,23.2969904 15.4802906,23.0681896 L15.4796772,16.3617812 L15.4796772,16.3617812 L9.42693239,19.2936488 C9.54250354,19.9090101 9.36818637,20.5691625 8.90013616,21.0538426 C8.13283771,21.8484034 6.86670062,21.8705039 6.07213982,21.1032055 C5.27757902,20.335907 5.25547851,19.06977 6.02277696,18.2752092 C6.79007541,17.4806484 8.0562125,17.4585478 8.8507733,18.2258463 C8.90464955,18.277874 8.95497425,18.3321952 9.00174214,18.3885073 L14.8957415,15.5335339 L8.95698016,12.663638 C8.54316409,13.1288103 7.91883307,13.3945629 7.25239963,13.3245179 C6.15388108,13.2090589 5.35695382,12.2249357 5.47241277,11.1264172 C5.58787172,10.0278986 6.57199493,9.23097136 7.67051349,9.34643031 C8.76903204,9.46188927 9.5659593,10.4460125 9.45050035,11.544531 C9.44231425,11.6224166 9.42976147,11.6987861 9.41311084,11.7734218 L15.4795257,14.705006 L15.4789062,7.93143834 C14.6270158,7.70216703 14,6.9243072 14,6 C14,4.8954305 14.8954305,4 16,4 C17.1045695,4 18,4.8954305 18,6 C18,6.93950562 17.3521946,7.72770818 16.4788902,7.94230133 L16.4795143,14.7663758 L22.5940736,11.8045661 C22.4397082,11.1620316 22.6068068,10.4567329 23.0998638,9.94615736 C23.8671623,9.15159656 25.1332994,9.12949606 25.9278602,9.8967945 C26.722421,10.664093 26.7445215,11.93023 25.977223,12.7247908 C25.2099246,13.5193516 23.9437875,13.5414522 23.1492267,12.7741537 C23.120046,12.7459743 23.0919072,12.717122 23.0648111,12.687645 L17.1917924,15.5324558 L23.0283963,18.3529842 C23.4420438,17.8775358 24.073269,17.604607 24.7476004,17.6754821 C25.8461189,17.7909411 26.6430462,18.7750643 26.5275872,19.8735828 C26.4121283,20.9721014 25.4280051,21.7690286 24.3294865,21.6535697 C23.230968,21.5381107 22.4340407,20.5539875 22.5494996,19.455469 C22.5569037,19.3850239 22.56788,19.315819 22.5822296,19.2480155 L16.4796545,16.298957 Z M16.0651172,6.99791382 C16.5870517,6.96436642 17,6.53040783 17,6 C17,5.44771525 16.5522847,5 16,5 C15.4477153,5 15,5.44771525 15,6 C15,6.53446591 15.4192913,6.9710011 15.9468816,6.99861337 L16.0651172,6.99791382 L16.0651172,6.99791382 Z M16,26 C16.5522847,26 17,25.5522847 17,25 C17,24.4477153 16.5522847,24 16,24 C15.4477153,24 15,24.4477153 15,25 C15,25.5522847 15.4477153,26 16,26 Z M6.56266251,20.102897 C6.80476821,20.5992873 7.40343746,20.8054256 7.89982771,20.5633199 C8.39621795,20.3212142 8.60235631,19.722545 8.36025061,19.2261547 C8.11814491,18.7297645 7.51947566,18.5236261 7.02308541,18.7657318 C6.52669517,19.0078375 6.32055681,19.6065068 6.56266251,20.102897 Z M23.6397494,11.7738453 C23.8818551,12.2702355 24.4805243,12.4763739 24.9769146,12.2342682 C25.4733048,11.9921625 25.6794432,11.3934932 25.4373375,10.897103 C25.1952318,10.4007127 24.5965625,10.1945744 24.1001723,10.4366801 C23.603782,10.6787858 23.3976437,11.277455 23.6397494,11.7738453 Z M25.4373375,20.102897 C25.6794432,19.6065068 25.4733048,19.0078375 24.9769146,18.7657318 C24.4805243,18.5236261 23.8818551,18.7297645 23.6397494,19.2261547 C23.3976437,19.722545 23.603782,20.3212142 24.1001723,20.5633199 C24.5965625,20.8054256 25.1952318,20.5992873 25.4373375,20.102897 Z M8.36025061,11.7738453 C8.60235631,11.277455 8.39621795,10.6787858 7.89982771,10.4366801 C7.40343746,10.1945744 6.80476821,10.4007127 6.56266251,10.897103 C6.32055681,11.3934932 6.52669517,11.9921625 7.02308541,12.2342682 C7.51947566,12.4763739 8.11814491,12.2702355 8.36025061,11.7738453 Z',
     external: 'M13.7780693,4.44451732 L5.1588494,4.44451732 C2.32615959,4.44451732 0,6.75504816 0,9.60367661 L0,25.1192379 C0,27.9699171 2.30950226,30.2783972 5.1588494,30.2783972 L18.9527718,30.2783972 C21.7854617,30.2783972 24.1116212,27.9678664 24.1116212,25.1192379 L24.1116212,19.9448453 L20.6671039,19.9448453 L20.6671039,25.1192379 C20.6671039,26.0662085 19.882332,26.8338799 18.9527718,26.8338799 L5.1588494,26.8338799 C4.21204994,26.8338799 3.44451732,26.0677556 3.44451732,25.1192379 L3.44451732,9.60367661 C3.44451732,8.656706 4.22928927,7.88903464 5.1588494,7.88903464 L13.7780693,7.88903464 L13.7780693,4.44451732 L13.7780693,4.44451732 Z M30.9990919,14.455325 L30.9990919,1 L17.5437669,1 L22.4834088,5.93964193 L17.2225866,11.2004641 L20.8001918,14.7780693 L26.061014,9.51724709 L30.9990919,14.455325 L30.9990919,14.455325 L30.9990919,14.455325 Z',
     eye: 'M30.622 18.49c-.549.769-1.46 1.86-2.737 3.273-1.276 1.414-2.564 2.614-3.866 3.602-2.297 1.757-4.963 2.635-8 2.635-3.062 0-5.741-.878-8.038-2.635-1.302-.988-2.59-2.188-3.866-3.602-1.276-1.413-2.188-2.504-2.737-3.272-.549-.769-.9-1.277-1.053-1.524-.433-.63-.433-1.276 0-1.934.128-.247.472-.755 1.034-1.524.561-.768 1.48-1.852 2.756-3.252 1.276-1.4 2.564-2.593 3.866-3.581C10.303 4.892 12.982 4 16.019 4c3.011 0 5.678.892 8 2.676 1.302.988 2.59 2.182 3.866 3.581 1.276 1.4 2.195 2.484 2.756 3.252.562.769.906 1.277 1.034 1.524.433.63.433 1.276 0 1.934-.153.247-.504.755-1.053 1.524zm-1.516-3.214c-.248.376-.248 1.089.034 1.499l-.11-.16-.088-.17a21.93 21.93 0 0 0-.784-1.121c-.483-.66-1.338-1.67-2.546-2.995-1.154-1.266-2.306-2.333-3.466-3.214-1.781-1.368-3.788-2.04-6.127-2.04-2.365 0-4.385.673-6.179 2.05-1.146.87-2.298 1.938-3.452 3.204-1.208 1.325-2.063 2.334-2.546 2.995a21.93 21.93 0 0 0-.784 1.12l-.075.145-.09.135c.249-.376.249-1.089-.033-1.499l.08.122c.105.17.432.644.941 1.356.466.653 1.313 1.666 2.517 3 1.152 1.275 2.3 2.346 3.451 3.22 1.752 1.339 3.773 2.001 6.17 2.001 2.37 0 4.379-.661 6.14-2.008 1.143-.867 2.291-1.938 3.443-3.214 1.204-1.333 2.05-2.346 2.517-2.999.509-.712.836-1.186.942-1.356l.045-.071zm-17.353 5.663C10.584 19.709 10 18.237 10 16.522c0-1.744.584-3.224 1.753-4.439 1.168-1.215 2.59-1.822 4.268-1.822 1.65 0 3.058.607 4.226 1.822C21.416 13.298 22 14.778 22 16.522c0 1.715-.584 3.187-1.753 4.417-1.168 1.229-2.577 1.844-4.226 1.844-1.677 0-3.1-.615-4.268-1.844zm6.265-2.12c.624-.655.906-1.368.906-2.297 0-.957-.281-1.67-.893-2.307-.592-.616-1.203-.879-2.01-.879-.84 0-1.462.266-2.052.88-.612.636-.893 1.35-.893 2.306 0 .929.282 1.642.906 2.298.59.62 1.207.887 2.039.887.8 0 1.405-.264 1.997-.887z',
@@ -108,36 +105,29 @@ export var ICON_PATHS = {
     },
     label: 'M14.577 31.042a2.005 2.005 0 0 1-2.738-.733L1.707 12.759c-.277-.477-.298-1.265-.049-1.757L6.45 1.537C6.7 1.044 7.35.67 7.9.7l10.593.582c.551.03 1.22.44 1.498.921l10.132 17.55a2.002 2.002 0 0 1-.734 2.737l-14.812 8.552zm.215-22.763a3.016 3.016 0 1 0-5.224 3.016 3.016 3.016 0 0 0 5.224-3.016z',
     left: "M21,0 L5,16 L21,32 L21,5.47117907e-13 L21,0 Z",
-    line: {
-        path: 'M17.5684644,16.0668074 L15.9388754,14.3793187 L15.8968592,14.4198933 L15.8953638,14.4183447 L15.8994949,14.4142136 L15.6628229,14.1775415 L15.5851122,14.0970697 L15.5837075,14.0984261 L14.4852814,13 L7.56742615,19.9178552 L8.98163972,21.3320688 L14.4809348,15.8327737 L14.4809348,15.8327737 L16.1103863,17.52012 L16.1522861,17.4796579 L16.1522861,17.4796579 L16.1539209,17.4813508 L16.1500476,17.4852242 L16.3719504,17.707127 L16.4640332,17.8024814 L16.4656976,17.8008741 L17.5643756,18.8995521 L24.4820322,11.9818955 L23.0677042,10.5675676 L17.5684644,16.0668074 Z',
-        attrs: { scale: 2 }
-    },
+    line: 'M18.867 16.377l-3.074-3.184-.08.077-.002-.002.01-.01-.53-.528-.066-.07-.001.002-2.071-2.072L-.002 23.645l2.668 2.668 10.377-10.377 3.074 3.183.08-.076.001.003-.008.008.5.501.094.097.002-.001 2.072 2.072L31.912 8.669 29.244 6 18.867 16.377z',
     list: 'M3 8 A3 3 0 0 0 9 8 A3 3 0 0 0 3 8 M12 6 L28 6 L28 10 L12 10z M3 16 A3 3 0 0 0 9 16 A3 3 0 0 0 3 16 M12 14 L28 14 L28 18 L12 18z M3 24 A3 3 0 0 0 9 24 A3 3 0 0 0 3 24 M12 22 L28 22 L28 26 L12 26z',
     location: {
         path: 'M19.4917776,13.9890373 C20.4445763,12.5611169 21,10.8454215 21,9 C21,4.02943725 16.9705627,0 12,0 C7.02943725,0 3,4.02943725 3,9 C3,10.8454215 3.5554237,12.5611168 4.50822232,13.9890371 L4.49999986,14.0000004 L4.58010869,14.0951296 C4.91305602,14.5790657 5.29212089,15.0288088 5.71096065,15.4380163 L12.5,23.5 L19.4999993,13.9999996 L19.4917776,13.9890373 L19.4917776,13.9890373 Z M12,12 C13.6568542,12 15,10.6568542 15,9 C15,7.34314575 13.6568542,6 12,6 C10.3431458,6 9,7.34314575 9,9 C9,10.6568542 10.3431458,12 12,12 Z',
         attrs: { viewBox: '0 0 24 24' }
     },
-    lock: 'M8.8125,13.2659641 L5.50307055,13.2659641 C4.93891776,13.2659641 4.5,13.7132101 4.5,14.2649158 L4.5,30.8472021 C4.5,31.4051918 4.94908998,31.8461538 5.50307055,31.8461538 L26.4969294,31.8461538 C27.0610822,31.8461538 27.5,31.3989079 27.5,30.8472021 L27.5,14.2649158 C27.5,13.7069262 27.05091,13.2659641 26.4969294,13.2659641 L23.1875,13.2659641 L23.1875,7.18200446 C23.1875,3.22368836 19.9695466,0 16,0 C12.0385306,0 8.8125,3.21549292 8.8125,7.18200446 L8.8125,13.2659641 Z M12.3509615,7.187641 C12.3509615,5.17225484 13.9813894,3.53846154 15.9955768,3.53846154 C18.0084423,3.53846154 19.6401921,5.17309313 19.6401921,7.187641 L19.6401921,13.0473232 L12.3509615,13.0473232 L12.3509615,7.187641 Z',
+    lock: {
+        path: 'M7.30894737,12.4444444 L4.91725192,12.4444444 C3.2943422,12.4444444 2,13.7457504 2,15.3509926 L2,29.0934518 C2,30.7017608 3.30609817,32 4.91725192,32 L27.0827481,32 C28.7056578,32 30,30.6986941 30,29.0934518 L30,15.3509926 C30,13.7426837 28.6939018,12.4444444 27.0827481,12.4444444 L24.6910526,12.4444444 L24.6910526,7.44176009 C24.6910526,3.33441301 21.3568185,0 17.2438323,0 L14.7561677,0 C10.6398254,0 7.30894737,3.33178948 7.30894737,7.44176009 L7.30894737,12.4444444 Z M10.8678947,8.21027479 C10.8678947,5.65010176 12.9450109,3.57467145 15.5045167,3.57467145 L16.4954833,3.57467145 C19.0562189,3.57467145 21.1321053,5.65531119 21.1321053,8.21027479 L21.1321053,12.8458781 L10.8678947,12.8458781 L10.8678947,8.21027479 Z M16,26.6666667 C17.9329966,26.6666667 19.5,25.0747902 19.5,23.1111111 C19.5,21.147432 17.9329966,19.5555556 16,19.5555556 C14.0670034,19.5555556 12.5,21.147432 12.5,23.1111111 C12.5,25.0747902 14.0670034,26.6666667 16,26.6666667 Z',
+        attrs: { fillRule: "evenodd" }
+    },
+    lockoutline: 'M7 12H5.546A3.548 3.548 0 0 0 2 15.553v12.894A3.547 3.547 0 0 0 5.546 32h20.908C28.414 32 30 30.41 30 28.447V15.553A3.547 3.547 0 0 0 26.454 12H25V8.99C25 4.029 20.97 0 16 0c-4.972 0-9 4.025-9 8.99V12zm4-3.766c0-2.338 1.89-4.413 4.219-4.634L16 3.525l.781.075C19.111 3.82 21 5.896 21 8.234V12H11V8.234zm-5 9.537C6 16.793 6.796 16 7.775 16h16.45c.98 0 1.775.787 1.775 1.77v8.46c0 .977-.796 1.77-1.775 1.77H7.775A1.77 1.77 0 0 1 6 26.23v-8.46zM16 25a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
     mail: {
         path: 'M0 6 L16 16 L32 6 z M0 9 L0 26 L32 26 L32 9 L16 19 z',
         attrs: { viewBox: '0 0 32 32' }
     },
     mine: 'M28.4907419,50 C25.5584999,53.6578499 21.0527692,56 16,56 C10.9472308,56 6.44150015,53.6578499 3.50925809,50 L28.4907419,50 Z M29.8594823,31.9999955 C27.0930063,27.217587 21.922257,24 16,24 C10.077743,24 4.9069937,27.217587 2.1405177,31.9999955 L29.8594849,32 Z M16,21 C19.8659932,21 23,17.1944204 23,12.5 C23,7.80557963 22,3 16,3 C10,3 9,7.80557963 9,12.5 C9,17.1944204 12.1340068,21 16,21 Z',
     moon: 'M11.6291702,1.84239429e-11 C19.1234093,1.22958025 24.8413559,7.73631246 24.8413559,15.5785426 C24.8413559,24.2977683 17.7730269,31.3660972 9.05380131,31.3660972 C7.28632096,31.3660972 5.58667863,31.0756481 4,30.5398754 C11.5007933,28.2096945 16.9475786,21.2145715 16.9475786,12.9472835 C16.9475786,7.90001143 14.9174312,3.32690564 11.6291702,1.70246039e-11 L11.6291702,1.84239429e-11 Z',
-    number: {
-        path: 'M8,8.4963932 C8,8.22224281 8.22618103,8 8.4963932,8 L23.5036068,8 C23.7777572,8 24,8.22618103 24,8.4963932 L24,23.5036068 C24,23.7777572 23.773819,24 23.5036068,24 L8.4963932,24 C8.22224281,24 8,23.773819 8,23.5036068 L8,8.4963932 Z M12.136,19 L12.136,13.4 L11.232,13.4 C11.1999998,13.6133344 11.1333338,13.7919993 11.032,13.936 C10.9306662,14.0800007 10.8066674,14.1959996 10.66,14.284 C10.5133326,14.3720004 10.3480009,14.4333332 10.164,14.468 C9.97999908,14.5026668 9.78933432,14.5173334 9.592,14.512 L9.592,15.368 L11,15.368 L11,19 L12.136,19 Z M13.616,16.176 C13.616,16.7360028 13.6706661,17.2039981 13.78,17.58 C13.8893339,17.9560019 14.0373324,18.2559989 14.224,18.48 C14.4106676,18.7040011 14.6279988,18.8639995 14.876,18.96 C15.1240012,19.0560005 15.3866653,19.104 15.664,19.104 C15.9466681,19.104 16.2119988,19.0560005 16.46,18.96 C16.7080012,18.8639995 16.9266657,18.7040011 17.116,18.48 C17.3053343,18.2559989 17.4546661,17.9560019 17.564,17.58 C17.6733339,17.2039981 17.728,16.7360028 17.728,16.176 C17.728,15.6319973 17.6733339,15.1746685 17.564,14.804 C17.4546661,14.4333315 17.3053343,14.1360011 17.116,13.912 C16.9266657,13.6879989 16.7080012,13.5280005 16.46,13.432 C16.2119988,13.3359995 15.9466681,13.288 15.664,13.288 C15.3866653,13.288 15.1240012,13.3359995 14.876,13.432 C14.6279988,13.5280005 14.4106676,13.6879989 14.224,13.912 C14.0373324,14.1360011 13.8893339,14.4333315 13.78,14.804 C13.6706661,15.1746685 13.616,15.6319973 13.616,16.176 Z M14.752,16.176 C14.752,16.0799995 14.7533333,15.9640007 14.756,15.828 C14.7586667,15.6919993 14.7679999,15.5520007 14.784,15.408 C14.8000001,15.2639993 14.8266665,15.121334 14.864,14.98 C14.9013335,14.838666 14.953333,14.7120006 15.02,14.6 C15.086667,14.4879994 15.1719995,14.3973337 15.276,14.328 C15.3800005,14.2586663 15.5093326,14.224 15.664,14.224 C15.8186674,14.224 15.9493328,14.2586663 16.056,14.328 C16.1626672,14.3973337 16.2506663,14.4879994 16.32,14.6 C16.3893337,14.7120006 16.4413332,14.838666 16.476,14.98 C16.5106668,15.121334 16.5373332,15.2639993 16.556,15.408 C16.5746668,15.5520007 16.5853333,15.6919993 16.588,15.828 C16.5906667,15.9640007 16.592,16.0799995 16.592,16.176 C16.592,16.3360008 16.5866667,16.5293322 16.576,16.756 C16.5653333,16.9826678 16.5320003,17.2013323 16.476,17.412 C16.4199997,17.6226677 16.329334,17.8026659 16.204,17.952 C16.078666,18.1013341 15.8986678,18.176 15.664,18.176 C15.4346655,18.176 15.2586673,18.1013341 15.136,17.952 C15.0133327,17.8026659 14.9240003,17.6226677 14.868,17.412 C14.8119997,17.2013323 14.7786667,16.9826678 14.768,16.756 C14.7573333,16.5293322 14.752,16.3360008 14.752,16.176 Z M18.064,16.176 C18.064,16.7360028 18.1186661,17.2039981 18.228,17.58 C18.3373339,17.9560019 18.4853324,18.2559989 18.672,18.48 C18.8586676,18.7040011 19.0759988,18.8639995 19.324,18.96 C19.5720012,19.0560005 19.8346653,19.104 20.112,19.104 C20.3946681,19.104 20.6599988,19.0560005 20.908,18.96 C21.1560012,18.8639995 21.3746657,18.7040011 21.564,18.48 C21.7533343,18.2559989 21.9026661,17.9560019 22.012,17.58 C22.1213339,17.2039981 22.176,16.7360028 22.176,16.176 C22.176,15.6319973 22.1213339,15.1746685 22.012,14.804 C21.9026661,14.4333315 21.7533343,14.1360011 21.564,13.912 C21.3746657,13.6879989 21.1560012,13.5280005 20.908,13.432 C20.6599988,13.3359995 20.3946681,13.288 20.112,13.288 C19.8346653,13.288 19.5720012,13.3359995 19.324,13.432 C19.0759988,13.5280005 18.8586676,13.6879989 18.672,13.912 C18.4853324,14.1360011 18.3373339,14.4333315 18.228,14.804 C18.1186661,15.1746685 18.064,15.6319973 18.064,16.176 Z M19.2,16.176 C19.2,16.0799995 19.2013333,15.9640007 19.204,15.828 C19.2066667,15.6919993 19.2159999,15.5520007 19.232,15.408 C19.2480001,15.2639993 19.2746665,15.121334 19.312,14.98 C19.3493335,14.838666 19.401333,14.7120006 19.468,14.6 C19.534667,14.4879994 19.6199995,14.3973337 19.724,14.328 C19.8280005,14.2586663 19.9573326,14.224 20.112,14.224 C20.2666674,14.224 20.3973328,14.2586663 20.504,14.328 C20.6106672,14.3973337 20.6986663,14.4879994 20.768,14.6 C20.8373337,14.7120006 20.8893332,14.838666 20.924,14.98 C20.9586668,15.121334 20.9853332,15.2639993 21.004,15.408 C21.0226668,15.5520007 21.0333333,15.6919993 21.036,15.828 C21.0386667,15.9640007 21.04,16.0799995 21.04,16.176 C21.04,16.3360008 21.0346667,16.5293322 21.024,16.756 C21.0133333,16.9826678 20.9800003,17.2013323 20.924,17.412 C20.8679997,17.6226677 20.777334,17.8026659 20.652,17.952 C20.526666,18.1013341 20.3466678,18.176 20.112,18.176 C19.8826655,18.176 19.7066673,18.1013341 19.584,17.952 C19.4613327,17.8026659 19.3720003,17.6226677 19.316,17.412 C19.2599997,17.2013323 19.2266667,16.9826678 19.216,16.756 C19.2053333,16.5293322 19.2,16.3360008 19.2,16.176 Z',
-        attrs: { scale: 2 }
-    },
+    move: 'M23.1253175,22.0483871 L15.3935224,22.0483871 L15.3935224,26.9516129 L23.1253175,26.9516129 L23.1253175,30.8741935 L31,24.0096774 L23.1253175,18.1258065 L23.1253175,22.0483871 L23.1253175,22.0483871 Z M17.5148597,5 L5.82641829,5 L5.82641829,27 L12.5834039,27 L12.5834039,32 L1,32 L1,0 L19.3006581,0 L28.0949312,9.19424889 L28.0949312,16.0165421 L23.2603172,16.0165421 L23.2603172,10.9520924 L17.5148597,5 Z',
+    number: 'M0 .503A.5.5 0 0 1 .503 0h30.994A.5.5 0 0 1 32 .503v30.994a.5.5 0 0 1-.503.503H.503A.5.5 0 0 1 0 31.497V.503zM8.272 22V10.8H6.464c-.064.427-.197.784-.4 1.072-.203.288-.45.52-.744.696a2.984 2.984 0 0 1-.992.368c-.368.07-.75.099-1.144.088v1.712H6V22h2.272zm2.96-5.648c0 1.12.11 2.056.328 2.808.219.752.515 1.352.888 1.8.373.448.808.768 1.304.96a4.327 4.327 0 0 0 1.576.288c.565 0 1.096-.096 1.592-.288a3.243 3.243 0 0 0 1.312-.96c.379-.448.677-1.048.896-1.8.219-.752.328-1.688.328-2.808 0-1.088-.11-2.003-.328-2.744-.219-.741-.517-1.336-.896-1.784a3.243 3.243 0 0 0-1.312-.96 4.371 4.371 0 0 0-1.592-.288c-.555 0-1.08.096-1.576.288-.496.192-.93.512-1.304.96-.373.448-.67 1.043-.888 1.784-.219.741-.328 1.656-.328 2.744zm2.272 0c0-.192.003-.424.008-.696.005-.272.024-.552.056-.84.032-.288.085-.573.16-.856a2.95 2.95 0 0 1 .312-.76 1.67 1.67 0 0 1 .512-.544c.208-.139.467-.208.776-.208.31 0 .57.07.784.208.213.139.39.32.528.544.139.224.243.477.312.76a7.8 7.8 0 0 1 .224 1.696 25.247 25.247 0 0 1-.024 1.856c-.021.453-.088.89-.2 1.312a2.754 2.754 0 0 1-.544 1.08c-.25.299-.61.448-1.08.448-.459 0-.81-.15-1.056-.448a2.815 2.815 0 0 1-.536-1.08 6.233 6.233 0 0 1-.2-1.312c-.021-.453-.032-.84-.032-1.16zm6.624 0c0 1.12.11 2.056.328 2.808.219.752.515 1.352.888 1.8.373.448.808.768 1.304.96a4.327 4.327 0 0 0 1.576.288c.565 0 1.096-.096 1.592-.288a3.243 3.243 0 0 0 1.312-.96c.379-.448.677-1.048.896-1.8.219-.752.328-1.688.328-2.808 0-1.088-.11-2.003-.328-2.744-.219-.741-.517-1.336-.896-1.784a3.243 3.243 0 0 0-1.312-.96 4.371 4.371 0 0 0-1.592-.288c-.555 0-1.08.096-1.576.288-.496.192-.93.512-1.304.96-.373.448-.67 1.043-.888 1.784-.219.741-.328 1.656-.328 2.744zm2.272 0c0-.192.003-.424.008-.696.005-.272.024-.552.056-.84.032-.288.085-.573.16-.856a2.95 2.95 0 0 1 .312-.76 1.67 1.67 0 0 1 .512-.544c.208-.139.467-.208.776-.208.31 0 .57.07.784.208.213.139.39.32.528.544.139.224.243.477.312.76a7.8 7.8 0 0 1 .224 1.696 25.247 25.247 0 0 1-.024 1.856c-.021.453-.088.89-.2 1.312a2.754 2.754 0 0 1-.544 1.08c-.25.299-.61.448-1.08.448-.459 0-.81-.15-1.056-.448a2.815 2.815 0 0 1-.536-1.08 6.233 6.233 0 0 1-.2-1.312c-.021-.453-.032-.84-.032-1.16z',
     pencil: 'M4.7352182,19.1979208 L11.3429107,25.5873267 L24.069853,12.5293069 L17.4624587,6.1419802 L4.7352182,19.1979208 Z M9.63604523,27.3406931 L3.02805455,20.9509901 L0.238146568,29.9610891 L9.63604523,27.3406931 Z M23.4499066,0 L19.1734989,4.38653465 L25.7811914,10.7759406 L30.0575991,6.38732673 L23.4499066,0 Z',
     permissionsLimited: 'M0,16 C0,7.163444 7.163444,0 16,0 C24.836556,0 32,7.163444 32,16 C32,24.836556 24.836556,32 16,32 C7.163444,32 0,24.836556 0,16 Z M29,16 C29,8.82029825 23.1797017,3 16,3 C8.82029825,3 3,8.82029825 3,16 C3,23.1797017 8.82029825,29 16,29 C23.1797017,29 29,23.1797017 29,16 Z M16,5 C11.0100706,5.11743299 5.14533409,7.90852303 5,15.5 C4.85466591,23.091477 11.0100706,26.882567 16,27 L16,5 Z',
-    pie: {
-        path: 'M16.0113299,15.368011 L16.0113299,7.6605591 L16.0113246,7.66055936 C16.1469053,7.65372627 16.283376,7.65026855 16.4206543,7.65026855 C18.4538187,7.65026855 20.309836,8.40872524 21.7212043,9.65813664 L16.0113299,15.368011 Z M16.5768268,16.0595929 L24.4103638,16.0595929 C24.4171966,15.9240175 24.4206543,15.7875468 24.4206543,15.6502686 C24.4206543,13.5849976 23.6380543,11.7025127 22.35323,10.2831897 L16.5768268,16.0595929 Z M24.2956851,17.0665012 L15.0044217,17.0665012 L15.0044217,7.77523777 C11.2616718,8.44383611 8.4206543,11.7152747 8.4206543,15.6502686 C8.4206543,20.0685466 12.0023763,23.6502686 16.4206543,23.6502686 C20.3556481,23.6502686 23.6270867,20.8092511 24.2956851,17.0665012 L24.2956851,17.0665012 Z',
-        attrs: { scale: 2 }
-    },
-    pinmap: {
-        path: 'M15,16.8999819 L15,21 L16,23 L17,21.0076904 L17,16.8999819 C16.6768901,16.9655697 16.3424658,17 16,17 C15.6575342,17 15.3231099,16.9655697 15,16.8999819 L15,16.8999819 Z M16,16 C18.209139,16 20,14.209139 20,12 C20,9.790861 18.209139,8 16,8 C13.790861,8 12,9.790861 12,12 C12,14.209139 13.790861,16 16,16 Z',
-        attrs: { scale: 2 }
-    },
+    pie: 'M15.181 15.435V.021a15.94 15.94 0 0 1 11.42 3.995l-11.42 11.42zm1.131 1.384H31.98a15.941 15.941 0 0 0-4.114-11.553L16.312 16.819zm15.438 2.013H13.168V.25C5.682 1.587 0 8.13 0 16c0 8.837 7.163 16 16 16 7.87 0 14.413-5.682 15.75-13.168z',
+    pinmap: 'M13.4 18.987v8.746L15.533 32l2.134-4.25v-8.763a10.716 10.716 0 0 1-4.267 0zm2.133-1.92a8.533 8.533 0 1 0 0-17.067 8.533 8.533 0 0 0 0 17.067z',
     popular: 'M23.29 11.224l-7.067 7.067-2.658-2.752.007-.007-.386-.385-.126-.131-.003.002-1.789-1.79L.705 23.793A.994.994 0 0 0 .704 25.2l.896.897a1 1 0 0 0 1.408-.002l8.253-8.252 2.654 2.748.226-.218-.161.161 1.152 1.152c.64.64 1.668.636 2.304 0l8.158-8.159L32 19.933V5H17.067l6.223 6.224z',
     recents: 'M15.689 17.292l-.689.344V6.992c0-.55.448-.992 1.001-.992h.907c.547 0 1.001.445 1.001.995v9.187l-.372.186 4.362 5.198a1.454 1.454 0 1 1-2.228 1.87L15 17.87l.689-.578zM16 32c8.837 0 16-7.163 16-16S24.837 0 16 0 0 7.163 0 16s7.163 16 16 16z',
     sql: {
@@ -145,10 +135,11 @@ export var ICON_PATHS = {
         attrs: { viewBox: '0 0 19 17' }
     },
     progress: {
-        path: 'M7,12.4677721 C7,11.6571439 7.65402301,11 8.45934956,11 L24.5406504,11 C25.3466269,11 26,11.6591176 26,12.4677721 L26,18.3014587 C26,19.1120868 25.345977,19.7692308 24.5406504,19.7692308 L8.45934956,19.7692308 C7.65337305,19.7692308 7,19.1101132 7,18.3014587 L7,12.4677721 L7,12.4677721 Z M19.3186813,12.4615385 L23.9137168,12.4615385 C24.3164099,12.4615385 24.6428571,12.7926035 24.6428571,13.1901374 L24.6428571,17.5790934 C24.6428571,17.9814874 24.3098061,18.3076923 23.9137168,18.3076923 L19.3186813,18.3076923 L19.3186813,12.4615385 L19.3186813,12.4615385 Z',
-        attrs: { scale: 2 }
+        path: 'M0 11.996A3.998 3.998 0 0 1 4.004 8h23.992A4 4 0 0 1 32 11.996v8.008A3.998 3.998 0 0 1 27.996 24H4.004A4 4 0 0 1 0 20.004v-8.008zM22 11h3.99A3.008 3.008 0 0 1 29 14v4c0 1.657-1.35 3-3.01 3H22V11z',
+        attrs: { fillRule: 'evenodd' }
     },
     sync: 'M16 2 A14 14 0 0 0 2 16 A14 14 0 0 0 16 30 A14 14 0 0 0 26 26 L 23.25 23 A10 10 0 0 1 16 26 A10 10 0 0 1 6 16 A10 10 0 0 1 16 6 A10 10 0 0 1 23.25 9 L19 13 L30 13 L30 2 L26 6 A14 14 0 0 0 16 2',
+    question: "M16,32 C24.836556,32 32,24.836556 32,16 C32,7.163444 24.836556,0 16,0 C7.163444,0 0,7.163444 0,16 C0,24.836556 7.163444,32 16,32 L16,32 Z M16,29.0909091 C8.77009055,29.0909091 2.90909091,23.2299095 2.90909091,16 C2.90909091,8.77009055 8.77009055,2.90909091 16,2.90909091 C23.2299095,2.90909091 29.0909091,8.77009055 29.0909091,16 C29.0909091,23.2299095 23.2299095,29.0909091 16,29.0909091 Z M12,9.56020942 C12.2727286,9.34380346 12.5694087,9.1413622 12.8900491,8.95287958 C13.2106896,8.76439696 13.5552807,8.59860455 13.9238329,8.45549738 C14.2923851,8.31239021 14.6885728,8.20069848 15.1124079,8.12041885 C15.5362429,8.04013921 15.9950835,8 16.4889435,8 C17.1818216,8 17.8065083,8.08725916 18.3630221,8.2617801 C18.919536,8.43630105 19.3931184,8.68586225 19.7837838,9.0104712 C20.1744491,9.33508016 20.4748147,9.7260012 20.6848894,10.1832461 C20.8949642,10.6404909 21,11.1483393 21,11.7068063 C21,12.2373499 20.9226052,12.6963331 20.7678133,13.0837696 C20.6130213,13.4712061 20.4176916,13.8080265 20.1818182,14.0942408 C19.9459448,14.3804552 19.6861194,14.6282712 19.4023342,14.8376963 C19.1185489,15.0471215 18.8495099,15.2408368 18.5952088,15.4188482 C18.3409078,15.5968595 18.1197798,15.773123 17.9318182,15.947644 C17.7438566,16.1221649 17.6240789,16.3176254 17.5724816,16.5340314 L17.2628993,18 L14.9189189,18 L14.6756757,16.3141361 C14.6167073,15.9720751 14.653562,15.6736487 14.7862408,15.4188482 C14.9189196,15.1640476 15.1013502,14.9336834 15.3335381,14.7277487 C15.565726,14.521814 15.8255514,14.3263535 16.1130221,14.1413613 C16.4004928,13.9563691 16.6695319,13.7574182 16.9201474,13.5445026 C17.1707629,13.3315871 17.3826773,13.0942421 17.5558968,12.8324607 C17.7291163,12.5706793 17.8157248,12.2582915 17.8157248,11.895288 C17.8157248,11.4764377 17.6701489,11.1431077 17.3789926,10.895288 C17.0878364,10.6474682 16.6879632,10.5235602 16.1793612,10.5235602 C15.7886958,10.5235602 15.462532,10.5619542 15.20086,10.6387435 C14.9391879,10.7155327 14.7143744,10.8010466 14.5264128,10.895288 C14.3384511,10.9895293 14.1744479,11.0750432 14.034398,11.1518325 C13.8943482,11.2286217 13.7543005,11.2670157 13.6142506,11.2670157 C13.2972957,11.2670157 13.0614258,11.1378721 12.9066339,10.8795812 L12,9.56020942 Z M14,22 C14,21.7192968 14.0511359,21.4580909 14.1534091,21.2163743 C14.2556823,20.9746577 14.3958324,20.7641335 14.5738636,20.5847953 C14.7518948,20.4054572 14.96212,20.2631584 15.2045455,20.1578947 C15.4469709,20.0526311 15.7121198,20 16,20 C16.2803044,20 16.5416655,20.0526311 16.7840909,20.1578947 C17.0265164,20.2631584 17.2386355,20.4054572 17.4204545,20.5847953 C17.6022736,20.7641335 17.7443177,20.9746577 17.8465909,21.2163743 C17.9488641,21.4580909 18,21.7192968 18,22 C18,22.2807032 17.9488641,22.5438584 17.8465909,22.7894737 C17.7443177,23.0350889 17.6022736,23.2475625 17.4204545,23.4269006 C17.2386355,23.6062387 17.0265164,23.7465882 16.7840909,23.8479532 C16.5416655,23.9493182 16.2803044,24 16,24 C15.7121198,24 15.4469709,23.9493182 15.2045455,23.8479532 C14.96212,23.7465882 14.7518948,23.6062387 14.5738636,23.4269006 C14.3958324,23.2475625 14.2556823,23.0350889 14.1534091,22.7894737 C14.0511359,22.5438584 14,22.2807032 14,22 Z",
     return:'M15.3040432,11.8500793 C22.1434689,13.0450349 27.291257,18.2496116 27.291257,24.4890512 C27.291257,25.7084278 27.0946472,26.8882798 26.7272246,28.0064033 L26.7272246,28.0064033 C25.214579,22.4825472 20.8068367,18.2141694 15.3040432,17.0604596 L15.3040432,25.1841972 L4.70874296,14.5888969 L15.3040432,3.99359668 L15.3040432,3.99359668 L15.3040432,11.8500793 Z',
     reference: {
         path: 'M15.9670388,2.91102126 L14.5202438,1.46422626 L14.5202438,13.9807372 C14.5202438,15.0873683 13.6272253,15.9844701 12.5215507,15.9844701 L2.89359,15.9844701 C2.16147687,15.9844701 1.446795,15.6184135 1.446795,14.5376751 L11.0747557,14.5376751 C12.1786034,14.5376751 13.0734488,13.6501624 13.0734488,12.5467556 L13.0734488,0 L2.17890813,0 C0,0 0,0 0,2.17890813 L0,14.5202438 C0,16.6991519 1.81285157,17.4312651 3.62570313,17.4312651 L13.9704736,17.4312651 C15.0731461,17.4312651 15.9670388,16.5448165 15.9670388,15.4275322 L15.9670388,2.91102126 Z',
@@ -160,10 +151,7 @@ export var ICON_PATHS = {
     search: 'M12 0 A12 12 0 0 0 0 12 A12 12 0 0 0 12 24 A12 12 0 0 0 18.5 22.25 L28 32 L32 28 L22.25 18.5 A12 12 0 0 0 24 12 A12 12 0 0 0 12 0 M12 4 A8 8 0 0 1 12 20 A8 8 0 0 1 12 4  ',
     segment: 'M2.99631547,14.0294075 L2.99631579,1.99517286 C2.99631582,0.893269315 3.89614282,0 4.98985651,0 L30.0064593,0 C31.1074614,0 32,0.895880847 32,2.00761243 L32,26.8688779 C32,27.9776516 31.1071386,28.8764903 30.0003242,28.8764903 L17.7266598,28.8764903 L17.7266594,14.0294075 L2.99631547,14.0294075 Z M-7.10651809e-15,16.9955967 L-7.10651809e-15,30.0075311 C-7.10651809e-15,31.1079413 0.900469916,32 2.00155906,32 L14.3949712,32 L14.3949712,16.9955967 L-7.10651809e-15,16.9955967 Z',
     star: 'M16 0 L21 11 L32 12 L23 19 L26 31 L16 25 L6 31 L9 19 L0 12 L11 11',
-    "star-outline": {
-        svg: '<g id="Artboard" stroke="currentcolor" stroke-width="2" fill="transparent"><path d="M10.672276,2.55093937 C11.2056915,3.37365026 12.4585188,7.09829907 12.4585188,7.09829907 C12.4585188,7.09829907 16.4821302,7.25831758 17.2971336,7.44748138 C18.112137,7.63664518 18.0531819,8.11422511 17.6084801,8.76510156 C17.1637783,9.41597801 13.9028665,11.820869 13.9028665,11.820869 C13.9028665,11.820869 15.1912016,15.6862929 15.1912022,16.4346216 C15.1912027,17.1829502 14.7612684,17.5037748 14.0793938,17.2755622 C13.3975192,17.0473497 9.93279891,14.8014656 9.93279891,14.8014656 C9.93279891,14.8014656 6.5173392,16.997499 5.87604591,17.2755622 C5.23475262,17.5536255 4.76491394,17.375714 4.76491402,16.4346212 C4.7649141,15.4935283 5.87604587,11.8208688 5.87604587,11.8208688 C5.87604587,11.8208688 2.76099881,9.28600552 2.28304414,8.76510156 C1.80508947,8.2441976 1.92187275,7.5961343 2.71410515,7.4474811 C3.50633756,7.29882791 7.41282797,7.09278507 7.41282797,7.09278507 C7.41282797,7.09278507 8.87870623,3.07026788 9.29319636,2.49805001 C9.70768649,1.92583215 10.1388604,1.72822849 10.672276,2.55093937 Z" id="Path-516"></path></g>',
-        attrs: { viewBox: '0 0 20 20'}
-    },
+    staroutline: "M16 21.935l5.967 3.14-1.14-6.653 4.828-4.712-6.671-.97L16 6.685l-2.984 6.053-6.67.971 4.827 4.712-1.14 6.654L16 21.935zm-9.892 8.547l1.89-11.029L0 11.647l11.053-1.609L16 0l4.947 10.038L32 11.647l-7.997 7.806 1.889 11.03L16 25.274l-9.892 5.207z",
     statemap: {
         path: 'M19.4375,6.97396734 L27,8.34417492 L27,25.5316749 L18.7837192,23.3765689 L11.875,25.3366259 L11.875,25.3366259 L11.875,11.0941749 L11.1875,11.0941749 L11.1875,25.5316749 L5,24.1566749 L5,7.65667492 L11.1875,9.03167492 L18.75,6.90135976 L18.75,22.0941749 L19.4375,22.0941749 L19.4375,6.97396734 Z',
         attrs: { scale: 2 }
@@ -173,19 +161,20 @@ export var ICON_PATHS = {
         attrs: { viewBox: '0 0 24 24'}
     },
     sun: 'M18.2857143,27.1999586 L18.2857143,29.7130168 C18.2857143,30.9760827 17.2711661,32 16,32 C14.7376349,32 13.7142857,30.9797942 13.7142857,29.7130168 L13.7142857,27.1999586 C14.4528227,27.3498737 15.2172209,27.4285714 16,27.4285714 C16.7827791,27.4285714 17.5471773,27.3498737 18.2857143,27.1999586 Z M13.7142857,4.80004141 L13.7142857,2.28698322 C13.7142857,1.02391726 14.7288339,0 16,0 C17.2623651,0 18.2857143,1.02020582 18.2857143,2.28698322 L18.2857143,4.80004141 C17.5471773,4.65012631 16.7827791,4.57142857 16,4.57142857 C15.2172209,4.57142857 14.4528227,4.65012631 13.7142857,4.80004141 Z M10.5518048,26.0488463 L8.93640145,27.9740091 C8.1245183,28.9415738 6.68916799,29.0738009 5.71539825,28.2567111 C4.74837044,27.4452784 4.62021518,26.0059593 5.43448399,25.0355515 L7.05102836,23.1090289 C8.00526005,24.3086326 9.1956215,25.3120077 10.5518048,26.0488463 Z M21.4481952,5.95115366 L23.0635986,4.02599087 C23.8754817,3.05842622 25.310832,2.92619908 26.2846018,3.74328891 C27.2516296,4.55472158 27.3797848,5.99404073 26.565516,6.96444852 L24.9489716,8.89097108 C23.9947399,7.69136735 22.8043785,6.68799226 21.4481952,5.95115366 Z M7.05102836,8.89097108 L5.43448399,6.96444852 C4.62260085,5.99688386 4.7416285,4.56037874 5.71539825,3.74328891 C6.68242605,2.93185624 8.12213263,3.05558308 8.93640145,4.02599087 L10.5518048,5.95115366 C9.1956215,6.68799226 8.00526005,7.69136735 7.05102836,8.89097108 Z M24.9489716,23.1090289 L26.565516,25.0355515 C27.3773992,26.0031161 27.2583715,27.4396213 26.2846018,28.2567111 C25.317574,29.0681438 23.8778674,28.9444169 23.0635986,27.9740091 L21.4481952,26.0488463 C22.8043785,25.3120077 23.9947399,24.3086326 24.9489716,23.1090289 Z M27.1999586,13.7142857 L29.7130168,13.7142857 C30.9760827,13.7142857 32,14.7288339 32,16 C32,17.2623651 30.9797942,18.2857143 29.7130168,18.2857143 L27.1999586,18.2857143 C27.3498737,17.5471773 27.4285714,16.7827791 27.4285714,16 C27.4285714,15.2172209 27.3498737,14.4528227 27.1999586,13.7142857 Z M4.80004141,18.2857143 L2.28698322,18.2857143 C1.02391726,18.2857143 2.7533531e-14,17.2711661 2.84217094e-14,16 C2.84217094e-14,14.7376349 1.02020582,13.7142857 2.28698322,13.7142857 L4.80004141,13.7142857 C4.65012631,14.4528227 4.57142857,15.2172209 4.57142857,16 C4.57142857,16.7827791 4.65012631,17.5471773 4.80004141,18.2857143 Z M16,22.8571429 C19.7870954,22.8571429 22.8571429,19.7870954 22.8571429,16 C22.8571429,12.2129046 19.7870954,9.14285714 16,9.14285714 C12.2129046,9.14285714 9.14285714,12.2129046 9.14285714,16 C9.14285714,19.7870954 12.2129046,22.8571429 16,22.8571429 Z',
-    table: {
-        path: 'M13.6373197,13.6373197 L18.3626803,13.6373197 L18.3626803,18.3626803 L13.6373197,18.3626803 L13.6373197,13.6373197 Z M18.9533504,18.9533504 L23.6787109,18.9533504 L23.6787109,23.6787109 L18.9533504,23.6787109 L18.9533504,18.9533504 Z M13.6373197,18.9533504 L18.3626803,18.9533504 L18.3626803,23.6787109 L13.6373197,23.6787109 L13.6373197,18.9533504 Z M8.32128906,18.9533504 L13.0466496,18.9533504 L13.0466496,23.6787109 L8.32128906,23.6787109 L8.32128906,18.9533504 Z M8.32128906,8.32128906 L13.0466496,8.32128906 L13.0466496,13.0466496 L8.32128906,13.0466496 L8.32128906,8.32128906 Z M8.32128906,13.6373197 L13.0466496,13.6373197 L13.0466496,18.3626803 L8.32128906,18.3626803 L8.32128906,13.6373197 Z M18.9533504,8.32128906 L23.6787109,8.32128906 L23.6787109,13.0466496 L18.9533504,13.0466496 L18.9533504,8.32128906 Z M18.9533504,13.6373197 L23.6787109,13.6373197 L23.6787109,18.3626803 L18.9533504,18.3626803 L18.9533504,13.6373197 Z M13.6373197,8.32128906 L18.3626803,8.32128906 L18.3626803,13.0466496 L13.6373197,13.0466496 L13.6373197,8.32128906 Z',
-        attrs: { scale: 2 }
-    },
+    table: 'M11.077 11.077h9.846v9.846h-9.846v-9.846zm11.077 11.077H32V32h-9.846v-9.846zm-11.077 0h9.846V32h-9.846v-9.846zM0 22.154h9.846V32H0v-9.846zM0 0h9.846v9.846H0V0zm0 11.077h9.846v9.846H0v-9.846zM22.154 0H32v9.846h-9.846V0zm0 11.077H32v9.846h-9.846v-9.846zM11.077 0h9.846v9.846h-9.846V0z',
     table2: {
         svg: '<g fill="currentcolor" fill-rule="evenodd"><path d="M10,19 L10,15 L3,15 L3,13 L10,13 L10,9 L12,9 L12,13 L20,13 L20,9 L22,9 L22,13 L29,13 L29,15 L22,15 L22,19 L29,19 L29,21 L22,21 L22,25 L20,25 L20,21 L12,21 L12,25 L10,25 L10,21 L3,21 L3,19 L10,19 L10,19 Z M12,19 L12,15 L20,15 L20,19 L12,19 Z M30.5,0 L32,0 L32,28 L30.5,28 L1.5,28 L0,28 L0,0 L1.5,0 L30.5,0 Z M29,3 L29,25 L3,25 L3,3 L29,3 Z M3,7 L29,7 L29,9 L3,9 L3,7 Z"></path></g>',
         attrs: { viewBox: '0 0 32 28' }
     },
     tilde: 'M.018 22.856s-.627-7.417 5.456-10.293c6.416-3.033 12.638 2.01 15.885 2.01 2.09 0 4.067-1.105 4.067-4.483 0-.118 6.563-.086 6.563-.086s.338 5.151-2.756 8.403c-3.095 3.251-7.314 2.899-7.314 2.899s-2.686 0-6.353-1.543c-4.922-2.07-6.494-1.348-7.095-.969-.6.38-1.863 1.04-1.863 4.062H.018z',
     trash: 'M4.31904507,29.7285487 C4.45843264,30.9830366 5.59537721,32 6.85726914,32 L20.5713023,32 C21.8337371,32 22.9701016,30.9833707 23.1095264,29.7285487 L25.1428571,11.4285714 L2.28571429,11.4285714 L4.31904507,29.7285487 L4.31904507,29.7285487 Z M6.85714286,4.57142857 L8.57142857,0 L18.8571429,0 L20.5714286,4.57142857 L25.1428571,4.57142857 C27.4285714,4.57142857 27.4285714,9.14285714 27.4285714,9.14285714 L13.7142857,9.14285714 L-1.0658141e-14,9.14285714 C-1.0658141e-14,9.14285714 -1.0658141e-14,4.57142857 2.28571429,4.57142857 L6.85714286,4.57142857 L6.85714286,4.57142857 Z M9.14285714,4.57142857 L18.2857143,4.57142857 L17.1428571,2.28571429 L10.2857143,2.28571429 L9.14285714,4.57142857 L9.14285714,4.57142857 Z',
-    unarchive: 'M19,11.2142857 L19,9.57142857 L24.8329973,9.57142857 L16.3066815,1 L7.78036568,9.57142857 L14,9.57142857 L14,11.2142857 L14,20 L10,20 L10,12.4285714 L3,12.4285714 L3,17.3809524 L5.34782609,17.3809524 L5.34782609,31 L27.6521739,31 L27.6521739,17.3809524 L30,17.3809524 L30,12.4285714 L23,12.4285714 L23,20 L19,20 L19,11.2142857 Z M10,20 L23,20 L23,23 L10,23 L10,20 Z',
+    unarchive: 'M18,7.95916837 L22.98085,7.97386236 L15.9779702,-0.00230793315 L9.02202984,7.93268248 L14,7.94736798 L14,22.8635899 L18,22.8635899 L18,7.95916837 Z M7,12.1176568 L0,12.1176568 L0,17.0882426 L3,17.0882426 L3,32 L29,32 L29,17.0882426 L32,17.0882426 L32,12.1176568 L25,12.1176568 L25,27.8341757 L7,27.8341757 L7,12.1176568 Z',
     unknown: 'M16.5,26.5 C22.0228475,26.5 26.5,22.0228475 26.5,16.5 C26.5,10.9771525 22.0228475,6.5 16.5,6.5 C10.9771525,6.5 6.5,10.9771525 6.5,16.5 C6.5,22.0228475 10.9771525,26.5 16.5,26.5 L16.5,26.5 Z M16.5,23.5 C12.6340068,23.5 9.5,20.3659932 9.5,16.5 C9.5,12.6340068 12.6340068,9.5 16.5,9.5 C20.3659932,9.5 23.5,12.6340068 23.5,16.5 C23.5,20.3659932 20.3659932,23.5 16.5,23.5 L16.5,23.5 Z',
     variable: 'M32,3.85760518 C32,5.35923081 31.5210404,6.55447236 30.5631068,7.4433657 C29.6051732,8.33225903 28.4358214,8.77669903 27.0550162,8.77669903 C26.2265331,8.77669903 25.4110072,8.67314019 24.6084142,8.46601942 C23.8058212,8.25889864 23.111114,8.05178097 22.5242718,7.84466019 C22.2481108,8.03452091 21.8425054,8.44875625 21.3074434,9.08737864 C20.7723814,9.72600104 20.1682882,10.5026923 19.4951456,11.4174757 C20.116508,14.0582656 20.6170423,15.9352695 20.9967638,17.0485437 C21.3764852,18.1618179 21.7389411,19.2880202 22.0841424,20.4271845 C22.3775635,21.3419679 22.8090586,22.0582498 23.3786408,22.5760518 C23.9482229,23.0938537 24.8457328,23.3527508 26.0711974,23.3527508 C26.5199591,23.3527508 27.0809028,23.2664518 27.7540453,23.0938511 C28.4271878,22.9212505 28.9795016,22.7486524 29.4110032,22.5760518 L28.8414239,24.9061489 C27.1326775,25.6310716 25.6397043,26.1574957 24.3624595,26.4854369 C23.0852148,26.8133781 21.9460676,26.9773463 20.9449838,26.9773463 C20.2200611,26.9773463 19.5037792,26.9083071 18.7961165,26.7702265 C18.0884539,26.632146 17.4412111,26.3818788 16.8543689,26.0194175 C16.2157465,25.6396961 15.6763776,25.1650514 15.236246,24.5954693 C14.7961143,24.0258871 14.4207135,23.2319361 14.1100324,22.2135922 C13.9029116,21.5749698 13.7130537,20.850058 13.5404531,20.038835 C13.3678524,19.2276119 13.1952544,18.51133 13.0226537,17.8899676 C12.5221118,18.6321504 12.1596559,19.1844642 11.9352751,19.5469256 C11.7108942,19.9093869 11.3829579,20.4185512 10.9514563,21.0744337 C9.5879112,23.1629015 8.4056145,24.6515597 7.40453074,25.5404531 C6.40344699,26.4293464 5.20389049,26.8737864 3.80582524,26.8737864 C2.75296129,26.8737864 1.85545139,26.5199604 1.11326861,25.8122977 C0.371085825,25.1046351 0,24.1812355 0,23.0420712 C0,21.5059254 0.478959612,20.2934241 1.4368932,19.4045307 C2.3948268,18.5156374 3.56417864,18.0711974 4.94498382,18.0711974 C5.77346693,18.0711974 6.56741799,18.1704413 7.32686084,18.368932 C8.08630369,18.5674228 8.80258563,18.7874853 9.47572816,19.0291262 C9.73462913,18.8220054 10.1359196,18.4164 10.6796117,17.8122977 C11.2233037,17.2081955 11.814452,16.4573939 12.4530744,15.5598706 C11.8834923,13.2470219 11.4174775,11.5037815 11.0550162,10.3300971 C10.6925548,9.15641269 10.321469,7.99137579 9.94174757,6.83495146 C9.63106641,5.90290796 9.18231146,5.18231107 8.59546926,4.67313916 C8.00862706,4.16396725 7.12837696,3.90938511 5.95469256,3.90938511 C5.43689061,3.90938511 4.85868712,3.99999909 4.22006472,4.18122977 C3.58144233,4.36246045 3.04638835,4.53074356 2.61488673,4.68608414 L3.18446602,2.35598706 C4.73787184,1.66558447 6.20927029,1.14779029 7.5987055,0.802588997 C8.98814071,0.457387702 10.1488627,0.284789644 11.0809061,0.284789644 C11.9266493,0.284789644 12.6515612,0.345198964 13.2556634,0.466019417 C13.8597657,0.586839871 14.4983785,0.845736958 15.171521,1.24271845 C15.7928834,1.62243987 16.3322523,2.10139948 16.789644,2.67961165 C17.2470357,3.25782382 17.6224365,4.04745994 17.9158576,5.04854369 C18.1229784,5.73894628 18.3128362,6.45522822 18.4854369,7.197411 C18.6580375,7.93959379 18.8047459,8.5782066 18.9255663,9.11326861 C19.2880277,8.56094654 19.6634285,7.99137294 20.0517799,7.40453074 C20.4401314,6.81768854 20.7723827,6.29989437 21.0485437,5.85113269 C22.3775687,3.76266485 23.5684953,2.2653767 24.6213592,1.3592233 C25.6742232,0.453069903 26.8651498,0 28.1941748,0 C29.2815588,0 30.1876986,0.358140971 30.9126214,1.07443366 C31.6375441,1.79072634 32,2.71844091 32,3.85760518 L32,3.85760518 Z',
+    viewArchive: {
+        path: 'M2.783 12.8h26.434V29H2.783V12.8zm6.956 3.4h12.522v2.6H9.739v-2.6zM0 4h32v6.4H0V4z',
+        attrs: { fillRule: "evenodd" }
+    },
     warning: {
         svg: '<g fill="currentcolor" fill-rule="evenodd"><path d="M10.9665007,4.7224988 C11.5372866,3.77118898 12.455761,3.75960159 13.0334993,4.7224988 L22.9665007,21.2775012 C23.5372866,22.228811 23.1029738,23 21.9950534,23 L2.00494659,23 C0.897645164,23 0.455760956,22.2403984 1.03349928,21.2775012 L10.9665007,4.7224988 Z M13.0184348,11.258 L13.0184348,14.69 C13.0184348,15.0580018 12.996435,15.4229982 12.9524348,15.785 C12.9084346,16.1470018 12.8504351,16.5159981 12.7784348,16.892 L11.5184348,16.892 C11.4464344,16.5159981 11.388435,16.1470018 11.3444348,15.785 C11.3004346,15.4229982 11.2784348,15.0580018 11.2784348,14.69 L11.2784348,11.258 L13.0184348,11.258 Z M11.0744348,19.058 C11.0744348,18.9139993 11.1014345,18.7800006 11.1554348,18.656 C11.2094351,18.5319994 11.2834343,18.4240005 11.3774348,18.332 C11.4714353,18.2399995 11.5824341,18.1670003 11.7104348,18.113 C11.8384354,18.0589997 11.978434,18.032 12.1304348,18.032 C12.2784355,18.032 12.4164341,18.0589997 12.5444348,18.113 C12.6724354,18.1670003 12.7844343,18.2399995 12.8804348,18.332 C12.9764353,18.4240005 13.0514345,18.5319994 13.1054348,18.656 C13.1594351,18.7800006 13.1864348,18.9139993 13.1864348,19.058 C13.1864348,19.2020007 13.1594351,19.3369994 13.1054348,19.463 C13.0514345,19.5890006 12.9764353,19.6979995 12.8804348,19.79 C12.7844343,19.8820005 12.6724354,19.9539997 12.5444348,20.006 C12.4164341,20.0580003 12.2784355,20.084 12.1304348,20.084 C11.978434,20.084 11.8384354,20.0580003 11.7104348,20.006 C11.5824341,19.9539997 11.4714353,19.8820005 11.3774348,19.79 C11.2834343,19.6979995 11.2094351,19.5890006 11.1554348,19.463 C11.1014345,19.3369994 11.0744348,19.2020007 11.0744348,19.058 Z"></path></g>',
         attrs: { viewBox: '0 0 23 23' }
@@ -194,6 +183,7 @@ export var ICON_PATHS = {
         path: 'M12.3069589,4.52260192 C14.3462632,1.2440969 17.653446,1.24541073 19.691933,4.52260192 L31.2249413,23.0637415 C33.2642456,26.3422466 31.7889628,29 27.9115531,29 L4.08733885,29 C0.218100769,29 -1.26453645,26.3409327 0.77395061,23.0637415 L12.3069589,4.52260192 Z M18.0499318,23.0163223 C18.0499772,23.0222378 18.05,23.0281606 18.05,23.0340907 C18.05,23.3266209 17.9947172,23.6030345 17.8840476,23.8612637 C17.7737568,24.1186089 17.6195847,24.3426723 17.4224081,24.5316332 C17.2266259,24.7192578 16.998292,24.8660439 16.7389806,24.9713892 C16.4782454,25.0773129 16.1979962,25.1301134 15.9,25.1301134 C15.5950083,25.1301134 15.3111795,25.0774024 15.0502239,24.9713892 C14.7901813,24.8657469 14.5629613,24.7183609 14.3703047,24.5298034 C14.177545,24.3411449 14.0258626,24.1177208 13.9159524,23.8612637 C13.8052827,23.6030345 13.75,23.3266209 13.75,23.0340907 C13.75,22.7411889 13.8054281,22.4661013 13.9165299,22.2109786 C14.0264627,21.9585404 14.1779817,21.7374046 14.3703047,21.5491736 C14.5621821,21.3613786 14.7883231,21.2126553 15.047143,21.1034656 C15.3089445,20.9930181 15.593871,20.938068 15.9,20.938068 C16.1991423,20.938068 16.4804862,20.9931136 16.7420615,21.1034656 C17.0001525,21.2123478 17.2274115,21.360472 17.4224081,21.5473437 C17.6191428,21.7358811 17.7731504,21.957652 17.88347,22.2109786 C17.9124619,22.2775526 17.9376628,22.3454862 17.9590769,22.414741 C18.0181943,22.5998533 18.05,22.7963729 18.05,23 C18.05,23.0054459 18.0499772,23.0108867 18.0499318,23.0163223 L18.0499318,23.0163223 Z M17.7477272,14.1749999 L17.7477272,8.75 L14.1170454,8.75 L14.1170454,14.1749999 C14.1170454,14.8471841 14.1572355,15.5139742 14.2376219,16.1753351 C14.3174838,16.8323805 14.4227217,17.5019113 14.5533248,18.1839498 L14.5921937,18.3869317 L17.272579,18.3869317 L17.3114479,18.1839498 C17.442051,17.5019113 17.5472889,16.8323805 17.6271507,16.1753351 C17.7075371,15.5139742 17.7477272,14.8471841 17.7477272,14.1749999 Z',
         attrs: { fillRule: "evenodd" }
     },
+    x: 'm11.271709,16 l-3.19744231e-13,4.728291 l4.728291,0 l16,11.271709 l27.271709,2.39808173e-13 l32,4.728291 l20.728291,16 l31.1615012,26.4332102 l26.4332102,31.1615012 l16,20.728291 l5.56678976,31.1615012 l0.838498756,26.4332102 l11.271709,16 z',
     "illustration-icon-pie": {
         svg: "<path d='M29.8065455,22.2351515 L15.7837576,15.9495758 L15.7837576,31.2174545 C22.0004848,31.2029091 27.3444848,27.5258182 29.8065455,22.2351515' fill='#78B5EC'></path><g id='Fill-1-+-Fill-3'><path d='M29.8065455,22.2351515 C30.7316364,20.2482424 31.2630303,18.0402424 31.2630303,15.7032727 C31.2630303,11.8138182 29.8220606,8.26763636 27.4569697,5.54472727 L15.7837576,15.9495758 L29.8065455,22.2351515' fill='#3875AC'></path><path d='M27.4569697,5.54472727 C24.6118788,2.26909091 20.4266667,0.188121212 15.7478788,0.188121212 C7.17963636,0.188121212 0.232727273,7.1350303 0.232727273,15.7032727 C0.232727273,24.2724848 7.17963636,31.2184242 15.7478788,31.2184242 C15.7604848,31.2184242 15.7721212,31.2174545 15.7837576,31.2174545 L15.7837576,15.9495758 L27.4569697,5.54472727' fill='#4C9DE6'></path></g>"
     },
diff --git a/frontend/src/metabase/lib/api.js b/frontend/src/metabase/lib/api.js
index c6edf9861b1b36bde7e66e28ff3c849a9aeef4b0..2361c2d8d99cf0c8d970713a0bfaf7ded725e2bc 100644
--- a/frontend/src/metabase/lib/api.js
+++ b/frontend/src/metabase/lib/api.js
@@ -62,7 +62,7 @@ function makeMethod(method: string, hasBody: boolean = false) {
                         let body = xhr.responseText;
                         try { body = JSON.parse(body); } catch (e) {}
                         if (xhr.status >= 200 && xhr.status <= 299) {
-                            resolve(transformResponse(body));
+                            resolve(transformResponse(body, { data }));
                         } else {
                             reject({
                                 status: xhr.status,
diff --git a/frontend/src/metabase/lib/dom.js b/frontend/src/metabase/lib/dom.js
index 71b6bb86aed5f7c806b978efe94210ce18599cb0..fc718bf5692208e29a63d4aba119daaea4537aed 100644
--- a/frontend/src/metabase/lib/dom.js
+++ b/frontend/src/metabase/lib/dom.js
@@ -54,3 +54,86 @@ export function elementIsInView(element, percentX = 1, percentY = 1) {
         return visiblePercentageX + tolerance > percentX && visiblePercentageY + tolerance > percentY;
     });
 }
+
+export function getSelectionPosition(element) {
+    // input, textarea, IE
+    if (element.setSelectionRange || element.createTextRange) {
+        return [element.selectionStart, element.selectionEnd];
+    }
+    // contenteditable
+    else {
+        try {
+            const selection = window.getSelection();
+            const range = selection.getRangeAt(0);
+            const { startContainer, startOffset } = range;
+            range.setStart(element, 0);
+            const end = range.toString().length;
+            range.setEnd(startContainer, startOffset);
+            const start = range.toString().length;
+
+            return [start, end];
+        } catch (e) {
+            return [0, 0];
+        }
+    }
+}
+
+export function setSelectionPosition(element, [start, end]) {
+    // input, textarea
+    if (element.setSelectionRange) {
+        element.focus();
+        element.setSelectionRange(start, end);
+    }
+    // IE
+    else if (element.createTextRange) {
+        const range = element.createTextRange();
+        range.collapse(true);
+        range.moveEnd("character", end);
+        range.moveStart("character", start);
+        range.select();
+    }
+    // contenteditable
+    else {
+        const selection = window.getSelection();
+        const startPos = getTextNodeAtPosition(element, start);
+        const endPos = getTextNodeAtPosition(element, end);
+        selection.removeAllRanges();
+        const range = new Range();
+        range.setStart(startPos.node, startPos.position);
+        range.setEnd(endPos.node, endPos.position);
+        selection.addRange(range);
+    }
+}
+
+export function saveSelection(element) {
+    let range = getSelectionPosition(element);
+    return () => setSelectionPosition(element, range);
+}
+
+export function getCaretPosition(element) {
+    return getSelectionPosition(element)[1];
+}
+
+export function setCaretPosition(element, position) {
+    setSelectionPosition(element, [position, position]);
+}
+
+export function saveCaretPosition(element) {
+    let position = getCaretPosition(element);
+    return () => setCaretPosition(element, position);
+}
+
+function getTextNodeAtPosition(root, index) {
+    let treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, (elem) => {
+        if (index > elem.textContent.length){
+            index -= elem.textContent.length;
+            return NodeFilter.FILTER_REJECT
+        }
+        return NodeFilter.FILTER_ACCEPT;
+    });
+    var c = treeWalker.nextNode();
+    return {
+        node: c ? c : root,
+        position: c ? index :  0
+    };
+}
diff --git a/frontend/src/metabase/lib/expressions.js b/frontend/src/metabase/lib/expressions.js
deleted file mode 100644
index 115fa5ab90f3f3ab5593dd763cf0a0faf7a9cd74..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/lib/expressions.js
+++ /dev/null
@@ -1,326 +0,0 @@
-
-import _ from "underscore";
-
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-// |                                                                      PREDICATE FUNCTIONS                                                                       |
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-
-const VALID_OPERATORS = new Set(['+', '-', '*', '/']);
-
-function isField(arg) {
-    return arg && arg.constructor === Array && arg.length === 2 && arg[0] === 'field-id' && typeof arg[1] === 'number';
-}
-
-export function isExpression(arg) {
-    return arg && arg.constructor === Array && arg.length === 3 && VALID_OPERATORS.has(arg[0]) && isValidArg(arg[1]) && isValidArg(arg[2]);
-}
-
-function isValidArg(arg) {
-    return isExpression(arg) || isField(arg) || typeof arg === 'number';
-}
-
-
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-// |                                                                   MBQL EXPRESSION -> STRING                                                                    |
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-
-function formatField(fieldRef, fields) {
-    let fieldID = fieldRef[1],
-        field   = _.findWhere(fields, {id: fieldID});
-
-    if (!field) throw 'field with ID does not exist: ' + fieldID;
-
-    let displayName = field.display_name;
-    return displayName.indexOf(' ') === -1 ? displayName : ('"' + displayName + '"');
-}
-
-function formatNestedExpression(expression, fields) {
-    return '(' + formatExpression(expression, fields) + ')';
-}
-
-function formatArg(arg, fields) {
-    if (!isValidArg(arg)) throw 'Invalid expression argument:' + arg;
-
-    return isField(arg)            ? formatField(arg, fields)            :
-           isExpression(arg)       ? formatNestedExpression(arg, fields) :
-           typeof arg === 'number' ? arg                                 :
-                                     null;
-}
-
-/// convert a parsed expression back into an expression string
-export function formatExpression(expression, fields) {
-    if (!expression)               return null;
-    if (!isExpression(expression)) throw 'Invalid expression: ' + expression;
-
-    let [operator, arg1, arg2] = expression;
-    let output = formatArg(arg1, fields) + ' ' + operator + ' ' + formatArg(arg2, fields);
-
-    return output;
-}
-
-
-
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-// |                                                                        STRING -> TOKENS                                                                        |
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-
-// takes the results of tokenizeExpression() and handles nesting the parentheses
-// e.g. ['(', {value: '"PRODUCT ID"', start: 0, end: 11}, {value: '+', start: 13, end: 14}, ')']
-// becomes [{isParent: true, value: [...], start: 0, end: 11}]
-function groupTokens(tokens) {
-    var groupsStack = [[]];
-
-    function push(item) {
-        _.last(groupsStack).push(item);
-    }
-
-    function pushNewGroup() {
-        groupsStack.push([]);
-    }
-
-    function closeGroup() {
-        if (groupsStack.length === 1) {
-            _.last(groupsStack)[0].error = 'Missing opening paren'; // set error on first element of topmost group
-        }
-
-        let group = _.last(groupsStack);
-        groupsStack.splice(-1); // pop the last group from the groups stack
-
-        push({
-            value: group,
-            start: group[0].start,
-            end: _.last(group).end,
-            isParent: true
-        });
-    }
-
-    for (var i = 0; i < tokens.length; i++) {
-        let token = tokens[i];
-
-        if      (token === '(') pushNewGroup();
-        else if (token === ')') closeGroup();
-        else                    push(token);
-    }
-
-    if (groupsStack.length > 1) {
-        closeGroup();
-        _.last(groupsStack[0]).error = 'Missing closing paren'; // set error on last element of top-level group
-    }
-
-    return groupsStack[0];
-}
-
-// take a string like '"PRODUCT ID" + (ID * 2)"'
-// and return tokens like [{value: '"PRODUCT ID"', start: 0, end: 11}, {value: '+', start: 13, end: 14}, '(', ...]
-function tokenizeExpression(expressionString) {
-    var i            = 0,
-        tokens       = [],
-        currentToken = null,
-        insideString = false;
-
-    function pushCurrentTokenIfExists() {
-        if (currentToken) {
-            currentToken.end = i;
-            tokens.push(currentToken);
-            currentToken = null;
-        }
-    }
-
-    function appendCharToCurrentToken(c) {
-        if (!currentToken) currentToken = {
-            start: i,
-            value: ''
-        };
-        currentToken.value += c;
-    }
-
-    // Replace operators in expressionString making sure the operators have exactly one space before and after
-    VALID_OPERATORS.forEach(function(operator) {
-        let regex = new RegExp("\\s*[\\" + operator + "]\\s*");
-        expressionString = expressionString.replace(regex, ' ' + operator + ' ');
-    });
-
-    for (; i < expressionString.length; i++) {
-        let c = expressionString.charAt(i);
-
-        if (c === '"') {
-            pushCurrentTokenIfExists();
-            insideString = !insideString;
-        }
-        else if (insideString) {
-            appendCharToCurrentToken(c);
-        }
-        else if (c === '(' || c === ')') {
-            pushCurrentTokenIfExists();
-            tokens.push(c);
-        }
-        else if (c === ' ' || c === '\n') {
-            pushCurrentTokenIfExists();
-        }
-        else {
-            appendCharToCurrentToken(c);
-        }
-    }
-
-    pushCurrentTokenIfExists();
-
-    return tokens;
-}
-
-
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-// |                                                                   TOKENS -> MBQL EXPRESSION                                                                    |
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-
-// takes a token and returns the appropriate MBQL form, e.g. a field name becomnes [field-id <id>]
-function tokenToMBQL(token, fields, operators) {
-    if (!token || typeof token !== 'object' || !token.value || !token.value.length) {
-        console.error('tokenization error: invalid token: ', token);
-        return null;
-    }
-
-    // check if token is a nested expression
-    if (token.isParent) {
-        token.value = annotateTokens(token.value, fields, operators);
-        return token;
-    }
-
-    // check if the token is a number
-    let numericValue = parseFloat(token.value);
-    if (!isNaN(numericValue)) {
-        token.parsedValue = numericValue;
-        return token;
-    }
-
-    // if not, it is a field name
-    let fieldName = token.value.replace(/^"?(.*)"?$/, '$1'); // strip off any quotes around the field name
-    token.suggestions = getFieldSuggestions(fieldName, fields);
-
-    let field = _.find(fields, function(field) {
-        return field.display_name.toLowerCase() === fieldName.toLowerCase();
-    });
-
-    if (field) token.parsedValue = ['field-id', field.id];
-    else       token.error = 'no field named "' + fieldName + '"';
-
-    return token;
-}
-
-// Add extra info about the tokens, like errors + suggestions
-function annotateTokens(tokens, fields, operators) {
-    // unnest excess parens
-    if (tokens.length === 1 && tokens[0].isParent) return annotateTokens(tokens[0].value, fields, operators);
-
-    let [lhs, operator, rhs] = tokens;
-
-    lhs = lhs ? tokenToMBQL(lhs, fields, operators) : {
-        token: '',
-        start: 0,
-        end: 0,
-        error: 'expression is empty',
-        suggestions: getFieldSuggestions('', fields),
-        suggestionsTitle: 'FIELDS'
-    };
-
-    if (operator && operator.value && operator.value.length) {
-        if (!operators.has(operator.value)) operator.error       = 'invalid operator: ' + operator.value;
-        else                                operator.parsedValue = operator.value;
-    } else {
-        operator = {
-            token: '',
-            start: lhs.end + 1,
-            end: lhs.end + 2,
-            error: 'missing operator',
-            suggestions: Array.from(operators).map((operator) => ({display_name: operator})),
-            suggestionsTitle: 'OPERATORS'
-        };
-    }
-
-    // if we have > 3 tokens group the rest
-    // TODO - this should be moved into groupTokens
-    if (tokens.length > 3) {
-        tokens = tokens.slice(2);
-        rhs = {
-            value: annotateTokens(tokens, fields, operators),
-            isParent: true,
-            start: tokens[0].start,
-            end: tokens[tokens.length - 1].end
-        };
-    }
-    else rhs = rhs ? tokenToMBQL(rhs, fields, operators) : {
-        token: '',
-        start: operator.end + 1,
-        end: operator.end + 2,
-        error: 'add something to the right of ' + operator.value,
-        suggestions: getFieldSuggestions('', fields),
-        suggestionsTitle: 'FIELDS'
-    };
-
-    return [lhs, operator, rhs];
-}
-
-
-// Takes a string representation of an expression and parses it into an array of structured tokens
-// the results still need to go through tokensToExpression to be converted to MBQL
-export function parseExpressionString(expression, fields) {
-    if (_.isEmpty(expression)) return [];
-
-    return annotateTokens(groupTokens(tokenizeExpression(expression)), fields, VALID_OPERATORS);
-}
-
-// Takes an array of tokens representing a parsed string based expression
-// and restructures them into a valid MBQL expression clause
-export function tokensToExpression(tokens) {
-    if (!tokens || tokens.constructor !== Array || tokens.length !== 3) return null;
-
-    var [lhs, operator, rhs] = tokens;
-
-    if (lhs.error)      throw lhs.error;
-    if (operator.error) throw operator.error;
-    if (rhs.error)      throw rhs.error;
-
-    operator = operator.parsedValue;
-    lhs = lhs.isParent ? tokensToExpression(lhs.value) : lhs.parsedValue;
-    rhs = rhs.isParent ? tokensToExpression(rhs.value) : rhs.parsedValue;
-
-    if (!operator) throw 'invalid operator!';
-    if (!lhs)      throw 'invalid lhs!';
-    if (!rhs)      throw 'invalid rhs!';
-
-    return [operator, lhs, rhs];
-}
-
-
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-// |                                                                              MISC                                                                              |
-// +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
-
-/// update suggestions with ones for fieldName
-function getFieldSuggestions(fieldName, fields) {
-    if (!fieldName) fieldName = '';
-
-    let suggestions = _.filter(fields, function(field) {
-        return field.display_name.toLowerCase().indexOf(fieldName.toLowerCase()) > -1;
-    });
-
-    // don't suggest anything if the only suggestion is for the token we already have
-    if (suggestions.length === 1 && suggestions[0].display_name === fieldName) suggestions = [];
-
-    return _.sortBy(suggestions, function(field) {
-        return field.display_name.toLowerCase();
-    });
-}
-
-
-// return the token underneath a cursor position
-export function tokenAtPosition(tokens, position) {
-    if (!tokens || !tokens.length) return null;
-
-    for (var i = 0; i < tokens.length; i++) {
-        let token = tokens[i];
-
-        if (token.start <= position && token.end >= position) {
-            return token.isParent ? tokenAtPosition(token.value, position) : token;
-        }
-    }
-}
diff --git a/frontend/src/metabase/lib/expressions/formatter.js b/frontend/src/metabase/lib/expressions/formatter.js
new file mode 100644
index 0000000000000000000000000000000000000000..c089d8cd1d1a3b32d0194417f591897fee9b8847
--- /dev/null
+++ b/frontend/src/metabase/lib/expressions/formatter.js
@@ -0,0 +1,76 @@
+
+import _ from "underscore";
+
+import {
+    VALID_OPERATORS, VALID_AGGREGATIONS,
+    isField, isMath, isMetric, isAggregation, isExpressionReference,
+    formatMetricName, formatFieldName, formatExpressionName
+} from "../expressions";
+
+// convert a MBQL expression back into an expression string
+export function format(expr, {
+    tableMetadata = {},
+    customFields = {},
+    operators = VALID_OPERATORS,
+    aggregations = VALID_AGGREGATIONS
+}, parens = false) {
+    const info = { tableMetadata, customFields, operators, aggregations };
+    if (expr == null || _.isEqual(expr, [])) {
+        return "";
+    }
+    if (typeof expr === "number") {
+        return formatLiteral(expr);
+    }
+    if (isField(expr)) {
+        return formatField(expr, info);
+    }
+    if (isMetric(expr)) {
+        return formatMetric(expr, info);
+    }
+    if (isMath(expr)) {
+        return formatMath(expr, info, parens);
+    }
+    if (isAggregation(expr)) {
+        return formatAggregation(expr, info);
+    }
+    if (isExpressionReference(expr)) {
+        return formatExpressionReference(expr, info);
+    }
+    throw new Error("Unknown expression " + JSON.stringify(expr));
+}
+
+function formatLiteral(expr) {
+    return JSON.stringify(expr);
+}
+
+function formatField([, fieldId], { tableMetadata: { fields } }) {
+    const field = _.findWhere(fields, { id: fieldId });
+    if (!field) {
+        throw 'field with ID does not exist: ' + fieldId;
+    }
+    return formatFieldName(field);
+}
+
+function formatMetric([, metricId], { tableMetadata: { metrics } }) {
+    const metric = _.findWhere(metrics, { id: metricId });
+    if (!metric) {
+        throw 'metric with ID does not exist: ' + metricId;
+    }
+    return formatMetricName(metric);
+}
+
+function formatExpressionReference([, expressionName]) {
+    return formatExpressionName(expressionName);
+}
+
+function formatMath([operator, ...args], info, parens) {
+    let formatted = args.map(arg => format(arg, info, true)).join(` ${operator} `)
+    return parens ? `(${formatted})` : formatted;
+}
+
+function formatAggregation([aggregation, ...args], info) {
+    const { aggregations } = info;
+    return args.length === 0 ?
+        aggregations.get(aggregation) :
+        `${aggregations.get(aggregation)}(${args.map(arg => format(arg, info)).join(", ")})`;
+}
diff --git a/frontend/src/metabase/lib/expressions/index.js b/frontend/src/metabase/lib/expressions/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e12463eba035c88843a7f6549fca59f27c84237e
--- /dev/null
+++ b/frontend/src/metabase/lib/expressions/index.js
@@ -0,0 +1,60 @@
+
+import _ from "underscore";
+import { mbqlEq } from "../query/util";
+
+import { VALID_OPERATORS, VALID_AGGREGATIONS } from "./tokens";
+
+export { VALID_OPERATORS, VALID_AGGREGATIONS } from "./tokens";
+
+export function formatAggregationName(aggregationOption) {
+    return VALID_AGGREGATIONS.get(aggregationOption.short);
+}
+
+function formatIdentifier(name) {
+    return /^\w+$/.test(name) ?
+        name :
+        JSON.stringify(name);
+}
+
+export function formatMetricName(metric) {
+    return formatIdentifier(metric.name);
+}
+
+export function formatFieldName(field) {
+    return formatIdentifier(field.display_name);
+}
+
+export function formatExpressionName(name) {
+    return formatIdentifier(name);
+}
+
+// move to query lib
+
+export function isExpression(expr) {
+    return isMath(expr) || isAggregation(expr) || isField(expr) || isMetric(expr) || isExpressionReference(expr);
+}
+
+export function isField(expr) {
+    return Array.isArray(expr) && expr.length === 2 && mbqlEq(expr[0], 'field-id') && typeof expr[1] === 'number';
+}
+
+export function isMetric(expr) {
+    // case sensitive, unlike most mbql
+    return Array.isArray(expr) && expr.length === 2 && expr[0] === "METRIC" && typeof expr[1] === 'number';
+}
+
+export function isMath(expr) {
+    return Array.isArray(expr) && VALID_OPERATORS.has(expr[0]) && _.all(expr.slice(1), isValidArg);
+}
+
+export function isAggregation(expr) {
+    return Array.isArray(expr) && VALID_AGGREGATIONS.has(expr[0]) && _.all(expr.slice(1), isValidArg);
+}
+
+export function isExpressionReference(expr) {
+    return Array.isArray(expr) && expr.length === 2 && mbqlEq(expr[0], 'expression') && typeof expr[1] === 'string';
+}
+
+export function isValidArg(arg) {
+    return isExpression(arg) || isField(arg) || typeof arg === 'number';
+}
diff --git a/frontend/src/metabase/lib/expressions/parser.js b/frontend/src/metabase/lib/expressions/parser.js
new file mode 100644
index 0000000000000000000000000000000000000000..d0ef13d025968e9737cfb459cab55ac86b2e2cd4
--- /dev/null
+++ b/frontend/src/metabase/lib/expressions/parser.js
@@ -0,0 +1,474 @@
+import { Lexer, Parser, getImage } from "chevrotain";
+
+import _ from "underscore";
+
+import { formatFieldName, formatExpressionName, formatAggregationName } from "../expressions";
+
+import {
+    VALID_AGGREGATIONS,
+    allTokens,
+    LParen, RParen,
+    AdditiveOperator, MultiplicativeOperator,
+    Aggregation, NullaryAggregation, UnaryAggregation,
+    StringLiteral, NumberLiteral, Minus,
+    Identifier
+} from "./tokens";
+
+const ExpressionsLexer = new Lexer(allTokens);
+
+const aggregationsMap = new Map(Array.from(VALID_AGGREGATIONS).map(([a,b]) => [b,a]));
+
+class ExpressionsParser extends Parser {
+    constructor(input, options = {}) {
+        const parserOptions = {
+            // recoveryEnabled: false,
+            ignoredIssues: {
+                // uses GATE to disambiguate fieldName and metricName
+                atomicExpression: { OR1: true }
+            }
+        };
+        super(input, allTokens, parserOptions);
+
+        let $ = this;
+
+        this._options = options;
+
+        // an expression without aggregations in it
+        $.RULE("expression", function (outsideAggregation = false) {
+            return $.SUBRULE($.additionExpression, [outsideAggregation])
+        });
+
+        // an expression with aggregations in it
+        $.RULE("aggregation", function () {
+            return $.SUBRULE($.additionExpression, [true])
+        });
+
+        // Lowest precedence thus it is first in the rule chain
+        // The precedence of binary expressions is determined by
+        // how far down the Parse Tree the binary expression appears.
+        $.RULE("additionExpression", (outsideAggregation) => {
+            let initial = $.SUBRULE($.multiplicationExpression, [outsideAggregation]);
+            let operations = $.MANY(() => {
+                const op = $.CONSUME(AdditiveOperator);
+                const rhsVal = $.SUBRULE2($.multiplicationExpression, [outsideAggregation]);
+                return [op, rhsVal];
+            });
+            return this._math(initial, operations);
+        });
+
+        $.RULE("multiplicationExpression", (outsideAggregation) => {
+            let initial = $.SUBRULE($.atomicExpression, [outsideAggregation]);
+            let operations = $.MANY(() => {
+                const op = $.CONSUME(MultiplicativeOperator);
+                const rhsVal = $.SUBRULE2($.atomicExpression, [outsideAggregation]);
+                return [op, rhsVal];
+            });
+            return this._math(initial, operations);
+        });
+
+        $.RULE("nullaryCall", () => {
+            return {
+                lParen: $.CONSUME(LParen),
+                rParen: $.CONSUME(RParen)
+            }
+        })
+        $.RULE("unaryCall", () => {
+            return {
+                lParen: $.CONSUME(LParen),
+                arg:    $.SUBRULE($.expression, [false]),
+                rParen: $.CONSUME(RParen)
+            }
+        })
+
+        $.RULE("aggregationExpression", (outsideAggregation) => {
+            const { aggregation, lParen, arg, rParen } = $.OR([
+                {ALT: () => ({
+                    aggregation: $.CONSUME(NullaryAggregation),
+                    ...$.OPTION(() => $.SUBRULE($.nullaryCall))
+                })},
+                {ALT: () => ({
+                    aggregation: $.CONSUME(UnaryAggregation),
+                    ...$.SUBRULE($.unaryCall)
+                })}
+            ]);
+            return this._aggregation(aggregation, lParen, arg, rParen);
+        });
+
+        $.RULE("metricExpression", () => {
+            const metricName = $.OR([
+                {ALT: () => $.SUBRULE($.stringLiteral) },
+                {ALT: () => $.SUBRULE($.identifier) }
+            ]);
+
+            const metric = this.getMetricForName(this._toString(metricName));
+            if (metric != null) {
+                return this._metricReference(metricName, metric.id);
+            }
+            return this._unknownMetric(metricName);
+        });
+
+        $.RULE("fieldExpression", () => {
+            const fieldName = $.OR([
+                {ALT: () => $.SUBRULE($.stringLiteral) },
+                {ALT: () => $.SUBRULE($.identifier) }
+            ]);
+
+            const field = this.getFieldForName(this._toString(fieldName));
+            if (field != null) {
+                return this._fieldReference(fieldName, field.id);
+            }
+            const expression = this.getExpressionForName(this._toString(fieldName));
+            if (expression != null) {
+                return this._expressionReference(fieldName, expression);
+            }
+            return this._unknownField(fieldName);
+        });
+
+        $.RULE("identifier", () => {
+            const identifier = $.CONSUME(Identifier);
+            return this._identifier(identifier);
+        })
+
+        $.RULE("stringLiteral", () => {
+            const stringLiteral = $.CONSUME(StringLiteral);
+            return this._stringLiteral(stringLiteral);
+        })
+
+        $.RULE("numberLiteral", () => {
+            const minus = $.OPTION(() => $.CONSUME(Minus));
+            const numberLiteral = $.CONSUME(NumberLiteral);
+            return this._numberLiteral(minus, numberLiteral);
+        })
+
+        $.RULE("atomicExpression", (outsideAggregation) => {
+            return $.OR([
+                // aggregations are not allowed inside other aggregations
+                {GATE: () => outsideAggregation, ALT: () => $.SUBRULE($.aggregationExpression, [false]) },
+
+                // NOTE: DISABLE METRICS
+                // {GATE: () => outsideAggregation, ALT: () => $.SUBRULE($.metricExpression) },
+
+                // fields are not allowed outside aggregations
+                {GATE: () => !outsideAggregation, ALT: () => $.SUBRULE($.fieldExpression) },
+
+                {ALT: () => $.SUBRULE($.parenthesisExpression, [outsideAggregation]) },
+                {ALT: () => $.SUBRULE($.numberLiteral) }
+            ], (outsideAggregation ? "aggregation" : "field name") + ", number, or expression");
+        });
+
+        $.RULE("parenthesisExpression", (outsideAggregation) => {
+            let lParen = $.CONSUME(LParen);
+            let expValue = $.SUBRULE($.expression, [outsideAggregation]);
+            let rParen = $.CONSUME(RParen);
+            return this._parens(lParen, expValue, rParen);
+        });
+
+        Parser.performSelfAnalysis(this);
+    }
+
+    getFieldForName(fieldName) {
+        const fields = this._options.tableMetadata && this._options.tableMetadata.fields;
+        return _.findWhere(fields, { display_name: fieldName });
+    }
+
+    getExpressionForName(expressionName) {
+        const customFields = this._options && this._options.customFields;
+        return customFields[expressionName];
+    }
+
+    getMetricForName(metricName) {
+        const metrics = this._options.tableMetadata && this._options.tableMetadata.metrics;
+        return _.find(metrics, (metric) => metric.name.toLowerCase() === metricName.toLowerCase());
+    }
+}
+
+class ExpressionsParserMBQL extends ExpressionsParser {
+    _math(initial, operations) {
+        for (const [op, rhsVal] of operations) {
+            // collapse multiple consecutive operators into a single MBQL statement
+            if (Array.isArray(initial) && initial[0] === op.image) {
+                initial.push(rhsVal);
+            } else {
+                initial = [op.image, initial, rhsVal]
+            }
+        }
+        return initial;
+    }
+    _aggregation(aggregation, lParen, arg, rParen) {
+        const agg = aggregationsMap.get(aggregation.image)
+        return arg == null ? [agg] : [agg, arg];
+    }
+    _metricReference(metricName, metricId) {
+        return ["METRIC", metricId];
+    }
+    _fieldReference(fieldName, fieldId) {
+        return ["field-id", fieldId];
+    }
+    _expressionReference(fieldName) {
+        return ["expression", fieldName];
+    }
+    _unknownField(fieldName) {
+        throw new Error("Unknown field \"" + fieldName + "\"");
+    }
+    _unknownMetric(metricName) {
+        throw new Error("Unknown metric \"" + metricName + "\"");
+    }
+
+    _identifier(identifier) {
+        return identifier.image;
+    }
+    _stringLiteral(stringLiteral) {
+        return JSON.parse(stringLiteral.image);
+    }
+    _numberLiteral(minus, numberLiteral) {
+        return parseFloat(numberLiteral.image) * (minus ? -1 : 1);
+    }
+    _parens(lParen, expValue, rParen) {
+        return expValue;
+    }
+    _toString(x) {
+        return x;
+    }
+}
+
+const syntax = (type, ...children) => ({
+    type: type,
+    children: children.filter(child => child)
+})
+const token = (token) => token && ({
+    type: "token",
+    text: token.image,
+    start: token.startOffset,
+    end: token.endOffset,
+});
+
+class ExpressionsParserSyntax extends ExpressionsParser {
+    _math(initial, operations) {
+        return syntax("math", ...[initial].concat(...operations.map(([op, arg]) => [token(op), arg])));
+    }
+    _aggregation(aggregation, lParen, arg, rParen) {
+        return syntax("aggregation", token(aggregation), token(lParen), arg, token(rParen));
+    }
+    _metricReference(metricName, metricId) {
+        return syntax("metric", metricName);
+    }
+    _fieldReference(fieldName, fieldId) {
+        return syntax("field", fieldName);
+    }
+    _expressionReference(fieldName) {
+        return syntax("expression-reference", token(fieldName));
+    }
+    _unknownField(fieldName) {
+        return syntax("unknown", fieldName);
+    }
+    _unknownMetric(metricName) {
+        return syntax("unknown", metricName);
+    }
+
+    _identifier(identifier) {
+        return syntax("identifier", token(identifier));
+    }
+    _stringLiteral(stringLiteral) {
+        return syntax("string", token(stringLiteral));
+    }
+    _numberLiteral(minus, numberLiteral) {
+        return syntax("number", token(minus), token(numberLiteral));
+    }
+    _parens(lParen, expValue, rParen) {
+        return syntax("group", token(lParen), expValue, token(rParen));
+    }
+    _toString(x) {
+        if (typeof x === "string") {
+            return x;
+        } else if (x.type === "string") {
+            return JSON.parse(x.children[0].text);
+        } else if (x.type === "identifier") {
+            return x.children[0].text;
+        }
+    }
+}
+
+function getSubTokenTypes(TokenClass) {
+    return TokenClass.extendingTokenTypes.map(tokenType => _.findWhere(allTokens, { tokenType }));
+}
+
+function getTokenSource(TokenClass) {
+    // strip regex escaping, e.x. "\+" -> "+"
+    return TokenClass.PATTERN.source.replace(/^\\/, "");
+}
+
+function run(Parser, source, options) {
+    if (!source) {
+        return [];
+    }
+    const { startRule } = options;
+    const parser = new Parser(ExpressionsLexer.tokenize(source).tokens, options);
+    const expression = parser[startRule]();
+    if (parser.errors.length > 0) {
+        for (const error of parser.errors) {
+            // clean up error messages
+            error.message = error.message && error.message
+                .replace(/^Expecting:?\s+/, "Expected ")
+                .replace(/--> (.*?) <--/g, "$1")
+                .replace(/(\n|\s)*but found:?/, " but found ")
+                .replace(/\s*but found\s+''$/, "");
+        }
+        throw parser.errors;
+    }
+    return expression;
+}
+
+export function compile(source, options = {}) {
+    return run(ExpressionsParserMBQL, source, options);
+}
+
+export function parse(source, options = {}) {
+    return run(ExpressionsParserSyntax, source, options);
+}
+
+// No need for more than one instance.
+const parserInstance = new ExpressionsParser([])
+export function suggest(source, {
+    tableMetadata,
+    customFields,
+    startRule,
+    index = source.length
+} = {}) {
+    const partialSource = source.slice(0, index);
+    const lexResult = ExpressionsLexer.tokenize(partialSource);
+    if (lexResult.errors.length > 0) {
+        throw new Error("sad sad panda, lexing errors detected");
+    }
+
+    const lastInputToken = _.last(lexResult.tokens)
+    let partialSuggestionMode = false
+    let assistanceTokenVector = lexResult.tokens
+
+    // we have requested assistance while inside an Identifier
+    if ((lastInputToken instanceof Identifier) &&
+        /\w/.test(partialSource[partialSource.length - 1])) {
+        assistanceTokenVector = assistanceTokenVector.slice(0, -1);
+        partialSuggestionMode = true
+    }
+
+
+    let finalSuggestions = []
+
+    // TODO: is there a better way to figure out which aggregation we're inside of?
+    const currentAggregationToken = _.find(assistanceTokenVector.slice().reverse(), (t) => t instanceof Aggregation);
+
+    const syntacticSuggestions = parserInstance.computeContentAssist(startRule, assistanceTokenVector)
+    for (const suggestion of syntacticSuggestions) {
+        const { nextTokenType, ruleStack } = suggestion;
+        // no nesting of aggregations or field references outside of aggregations
+        // we have a predicate in the grammar to prevent nested aggregations but chevrotain
+        // doesn't support predicates in content-assist mode, so we need this extra check
+        const outsideAggregation = startRule === "aggregation" && ruleStack.slice(0, -1).indexOf("aggregationExpression") < 0;
+
+        if (nextTokenType === MultiplicativeOperator || nextTokenType === AdditiveOperator) {
+            let tokens = getSubTokenTypes(nextTokenType);
+            finalSuggestions.push(...tokens.map(token => ({
+                type: "operators",
+                name: getTokenSource(token),
+                text: " " + getTokenSource(token) + " ",
+                prefixTrim: /\s*$/,
+                postfixTrim: /^\s*[*/+-]?\s*/
+            })))
+        } else if (nextTokenType === LParen) {
+            finalSuggestions.push({
+                type: "other",
+                name: "(",
+                text: " (",
+                postfixText: ")",
+                prefixTrim: /\s*$/,
+                postfixTrim: /^\s*\(?\s*/
+            });
+        } else if (nextTokenType === RParen) {
+            finalSuggestions.push({
+                type: "other",
+                name: ")",
+                text: ") ",
+                prefixTrim: /\s*$/,
+                postfixTrim: /^\s*\)?\s*/
+            });
+        } else if (nextTokenType === Identifier || nextTokenType === StringLiteral) {
+            if (!outsideAggregation) {
+                let fields = [];
+                if (startRule === "aggregation" && currentAggregationToken) {
+                    let aggregationShort = aggregationsMap.get(getImage(currentAggregationToken));
+                    let aggregationOption = _.findWhere(tableMetadata.aggregation_options, { short: aggregationShort });
+                    fields = aggregationOption && aggregationOption.fields && aggregationOption.fields[0] || []
+                } else if (startRule === "expression") {
+                    fields = tableMetadata.fields;
+                }
+                finalSuggestions.push(...fields.map(field => ({
+                    type: "fields",
+                    name: field.display_name,
+                    text: formatFieldName(field) + " ",
+                    prefixTrim: /\w+$/,
+                    postfixTrim: /^\w+\s*/
+                })));
+                finalSuggestions.push(...Object.keys(customFields || {}).map(expressionName => ({
+                    type: "fields",
+                    name: expressionName,
+                    text: formatExpressionName(expressionName) + " ",
+                    prefixTrim: /\w+$/,
+                    postfixTrim: /^\w+\s*/
+                })));
+            }
+        } else if (nextTokenType === Aggregation || nextTokenType === NullaryAggregation || nextTokenType === UnaryAggregation || nextTokenType === Identifier || nextTokenType === StringLiteral) {
+            if (outsideAggregation) {
+                finalSuggestions.push(...tableMetadata.aggregation_options.filter(a => formatAggregationName(a)).map(aggregationOption => {
+                    const arity = aggregationOption.fields.length;
+                    return {
+                        type: "aggregations",
+                        name: formatAggregationName(aggregationOption),
+                        text: formatAggregationName(aggregationOption) + (arity > 0 ? "(" : " "),
+                        postfixText: (arity > 0 ? ")" : " "),
+                        prefixTrim: /\w+$/,
+                        postfixTrim: (arity > 0 ? /^\w+(\(\)?|$)/ : /^\w+\s*/)
+                    };
+                }));
+                // NOTE: DISABLE METRICS
+                // finalSuggestions.push(...tableMetadata.metrics.map(metric => ({
+                //     type: "metrics",
+                //     name: metric.name,
+                //     text: formatMetricName(metric),
+                //     prefixTrim: /\w+$/,
+                //     postfixTrim: /^\w+\s*/
+                // })))
+            }
+        } else if (nextTokenType === NumberLiteral) {
+            // skip number literal
+        } else {
+            console.warn("non exhaustive match", nextTokenType.name, suggestion)
+        }
+    }
+
+    // throw away any suggestion that is not a suffix of the last partialToken.
+    if (partialSuggestionMode) {
+        const partial = getImage(lastInputToken).toLowerCase();
+        finalSuggestions = _.filter(finalSuggestions, (suggestion) =>
+            (suggestion.text && suggestion.text.toLowerCase().startsWith(partial)) ||
+            (suggestion.name && suggestion.name.toLowerCase().startsWith(partial))
+        );
+
+        let prefixLength = partial.length;
+        for (const suggestion of finalSuggestions) {
+            suggestion.prefixLength = prefixLength;
+        }
+    }
+    for (const suggestion of finalSuggestions) {
+        suggestion.index = index;
+        if (!suggestion.name) {
+            suggestion.name = suggestion.text;
+        }
+    }
+
+    // deduplicate suggestions and sort by type then name
+    return _.chain(finalSuggestions)
+        .uniq(suggestion => suggestion.text)
+        .sortBy("name")
+        .sortBy("type")
+        .value();
+}
diff --git a/frontend/src/metabase/lib/expressions/tokens.js b/frontend/src/metabase/lib/expressions/tokens.js
new file mode 100644
index 0000000000000000000000000000000000000000..9f875498159c0628a4eadc0f0faaaebae47a1535
--- /dev/null
+++ b/frontend/src/metabase/lib/expressions/tokens.js
@@ -0,0 +1,73 @@
+// Note: this file is imported by webpack.config.js
+
+import { Lexer, extendToken } from "chevrotain";
+
+export const VALID_OPERATORS = new Set([
+    '+',
+    '-',
+    '*',
+    '/'
+]);
+
+export const VALID_AGGREGATIONS = new Map(Object.entries({
+    "count": "Count",
+    "cum_count": "CumulativeCount",
+    "sum": "Sum",
+    "cum_sum": "CumulativeSum",
+    "distinct": "Distinct",
+    "stddev": "StandardDeviation",
+    "avg": "Average",
+    "min": "Min",
+    "max": "Max"
+}));
+
+export const NULLARY_AGGREGATIONS = ["count", "cum_count"];
+export const UNARY_AGGREGATIONS = ["sum", "cum_sum", "distinct", "stddev", "avg", "min", "max"];
+
+export const AdditiveOperator = extendToken("AdditiveOperator", Lexer.NA);
+export const Plus = extendToken("Plus", /\+/, AdditiveOperator);
+export const Minus = extendToken("Minus", /-/, AdditiveOperator);
+
+export const MultiplicativeOperator = extendToken("MultiplicativeOperator", Lexer.NA);
+export const Multi = extendToken("Multi", /\*/, MultiplicativeOperator);
+export const Div = extendToken("Div", /\//, MultiplicativeOperator);
+
+export const Aggregation = extendToken("Aggregation", Lexer.NA);
+
+export const NullaryAggregation = extendToken("NullaryAggregation", Aggregation);
+const nullaryAggregationTokens = NULLARY_AGGREGATIONS.map((short) =>
+    extendToken(VALID_AGGREGATIONS.get(short), new RegExp(VALID_AGGREGATIONS.get(short), "i"), NullaryAggregation)
+);
+
+export const UnaryAggregation = extendToken("UnaryAggregation", Aggregation);
+const unaryAggregationTokens = UNARY_AGGREGATIONS.map((short) =>
+    extendToken(VALID_AGGREGATIONS.get(short), new RegExp(VALID_AGGREGATIONS.get(short), "i"), UnaryAggregation)
+);
+
+export const Identifier = extendToken('Identifier', /\w+/);
+export const NumberLiteral = extendToken("NumberLiteral", /-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/);
+export const StringLiteral = extendToken("StringLiteral", /"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"/);
+
+export const Comma = extendToken('Comma', /,/);
+Comma.LABEL = "comma";
+
+export const LParen = extendToken('LParen', /\(/);
+LParen.LABEL = "opening parenthesis";
+
+export const RParen = extendToken('RParen', /\)/);
+RParen.LABEL = "closing parenthesis";
+
+export const WhiteSpace = extendToken("WhiteSpace", /\s+/);
+WhiteSpace.GROUP = Lexer.SKIPPED;
+
+// whitespace is normally very common so it is placed first to speed up the lexer
+export const allTokens = [
+    WhiteSpace, LParen, RParen, Comma,
+    Plus, Minus, Multi, Div,
+    AdditiveOperator, MultiplicativeOperator,
+    Aggregation,
+    NullaryAggregation, ...nullaryAggregationTokens,
+    UnaryAggregation, ...unaryAggregationTokens,
+    StringLiteral, NumberLiteral,
+    Identifier
+];
diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js
index f0f72f31516af38961268f5f703a3502448b3f6a..6ad1e12a27bd0c5961a83981cf32ae2f7cbfa7ef 100644
--- a/frontend/src/metabase/lib/query.js
+++ b/frontend/src/metabase/lib/query.js
@@ -7,17 +7,19 @@ import Utils from "metabase/lib/utils";
 import { getOperators } from "metabase/lib/schema_metadata";
 import { createLookupByProperty } from "metabase/lib/table";
 import { isFK, TYPE } from "metabase/lib/types";
+import { stripId } from "metabase/lib/formatting";
+import { format as formatExpression } from "metabase/lib/expressions/formatter";
 
 
+import * as Q from "./query/query";
+import { mbql, mbqlEq } from "./query/util";
+
 export const NEW_QUERY_TEMPLATES = {
     query: {
         database: null,
         type: "query",
         query: {
-            source_table: null,
-            aggregation: ["rows"],
-            breakout: [],
-            filter: []
+            source_table: null
         }
     },
     native: {
@@ -66,8 +68,8 @@ const METRIC_TYPE_BY_AGGREGATION = {
     "max": TYPE.Float,
 }
 
-const mbqlCanonicalize = (a) => typeof a === "string" ? a.toLowerCase().replace(/_/g, "-") : a;
-const mbqlCompare = (a, b) => mbqlCanonicalize(a) === mbqlCanonicalize(b)
+const SORTABLE_AGGREGATION_TYPES = new Set(["avg", "count", "distinct", "stddev", "sum", "min", "max"]);
+
 
 var Query = {
 
@@ -84,9 +86,19 @@ var Query = {
             return false;
         }
         // check that the table supports this aggregation, if we have tableMetadata
-        let agg = query.aggregation && query.aggregation[0] || "rows";
-        if (!mbqlCompare(agg, "metric") && tableMetadata && !_.findWhere(tableMetadata.aggregation_options, { short: agg })) {
-            return false;
+        if (tableMetadata) {
+            let aggs = Query.getAggregations(query);
+            if (aggs.length === 0) {
+                if (!_.findWhere(tableMetadata.aggregation_options, { short: "rows" })) {
+                    return false;
+                }
+            } else {
+                for (const [agg] of aggs) {
+                    if (!mbqlEq(agg, "metric") && !_.findWhere(tableMetadata.aggregation_options, { short: agg })) {
+                        // return false;
+                    }
+                }
+            }
         }
         return true;
     },
@@ -99,36 +111,26 @@ var Query = {
         // it's possible the user left some half-done parts of the query on screen when they hit the run button, so find those
         // things now and clear them out so that we have a nice clean set of valid clauses in our query
 
+        // aggregations
+        query.aggregation = Query.getAggregations(query);
+        if (query.aggregation.length === 0) {
+            delete query.aggregation;
+        }
+
         // breakouts
-        if (query.breakout) {
-            query.breakout = query.breakout.filter(b => b != null);
+        query.breakout = Query.getBreakouts(query);
+        if (query.breakout.length === 0) {
+            delete query.breakout;
         }
 
         // filters
-        var queryFilters = Query.getFilters(query);
-        if (queryFilters.length > 1) {
-            var hasNullValues = function(arr) {
-                for (var j=0; j < arr.length; j++) {
-                    if (arr[j] === null) {
-                        return true;
-                    }
-                }
-
-                return false;
-            };
-
-            var cleanFilters = [queryFilters[0]];
-            for (var i=1; i < queryFilters.length; i++) {
-                if (!hasNullValues(queryFilters[i])) {
-                    cleanFilters.push(queryFilters[i]);
-                }
-            }
-
-            if (cleanFilters.length > 1) {
-                query.filter = cleanFilters;
-            } else {
-                query.filter = [];
-            }
+        const filters = Query.getFilters(query).filter(filter =>
+            _.all(filter, a => a != null)
+        );
+        if (filters.length > 0) {
+            query.filter = ["AND", ...filters];
+        } else {
+            delete query.filter;
         }
 
         if (query.order_by) {
@@ -142,7 +144,7 @@ var Query = {
 
                 if (Query.isAggregateField(field)) {
                     // remove aggregation sort if we can't sort by this aggregation
-                    if (Query.canSortByAggregateField(query)) {
+                    if (Query.canSortByAggregateField(query, field[1])) {
                         return s;
                     }
                 } else if (Query.hasValidBreakout(query)) {
@@ -160,7 +162,7 @@ var Query = {
                         }
                         return [targetMatches[0], s[1]];
                     }
-                } else if (Query.isBareRowsAggregation(query)) {
+                } else if (Query.isBareRows(query)) {
                     return s;
                 }
 
@@ -203,145 +205,15 @@ var Query = {
                     query.breakout[0] !== null);
     },
 
-    canSortByAggregateField(query) {
-        var SORTABLE_AGGREGATION_TYPES = new Set(["avg", "count", "distinct", "stddev", "sum", "min", "max", "metric"]);
-
-        return Query.hasValidBreakout(query) && SORTABLE_AGGREGATION_TYPES.has(query.aggregation && query.aggregation[0].toLowerCase());
-    },
-
-    addDimension(query) {
-        query.breakout.push(null);
-    },
-
-    updateDimension(query, value, index) {
-        query.breakout = BreakoutClause.setBreakout(query.breakout, index, value);
-    },
-
-    removeDimension(query, index) {
-        let field = query.breakout[index];
-
-        // remove the field from the breakout clause
-        query.breakout = BreakoutClause.removeBreakout(query.breakout, index);
-
-        // remove sorts that referenced the dimension that was removed
-        if (query.order_by) {
-            query.order_by = query.order_by.filter(s => s[0] !== field);
-            if (query.order_by.length === 0) {
-                delete query.order_by;
-            }
-        }
-    },
-
-    hasEmptyAggregation(query) {
-        var aggregation = query.aggregation;
-        if (aggregation !== undefined &&
-                aggregation.length > 0 &&
-                aggregation[0] !== null) {
-            return false;
-        }
-        return true;
-    },
-
-    hasValidAggregation(query) {
-        var aggregation = query && query.aggregation;
-        if (aggregation &&
-                ((aggregation.length === 1 && aggregation[0] !== null) ||
-                 (aggregation.length === 2 && aggregation[0] !== null && aggregation[1] !== null))) {
-            return true;
-        }
-        return false;
-    },
-
-    isBareRowsAggregation(query) {
-        return (query.aggregation && query.aggregation[0] === "rows");
-    },
-
-    updateAggregation(query, aggregationClause) {
-        // when switching to or from "rows" aggregation clear out any sorting clauses
-        if ((query.aggregation[0] === "rows" || aggregationClause[0] === "rows") && query.aggregation[0] !== aggregationClause[0]) {
-            delete query.order_by;
-        }
-
-        query.aggregation = aggregationClause;
-
-        // for "rows" type aggregation we always clear out any dimensions because they don't make sense
-        if (aggregationClause.length > 0 && aggregationClause[0] === "rows") {
-            query.breakout = [];
-        }
-    },
-
-    getFilters(query) {
-        if (!query) throw 'query is null!';
-        // Special handling for accessing query filters because it's been fairly complex to deal with their structure.
-        // This method provide a unified and consistent view of the filter definition for the rest of the tool to use.
-
-        var queryFilters = query.filter;
-
-        // quick check for older style filter definitions and tweak them to a format we want to work with
-        if (queryFilters && queryFilters.length > 0 && queryFilters[0] !== "AND") {
-            var reformattedFilters = [];
-
-            for (var i=0; i < queryFilters.length; i++) {
-                if (queryFilters[i] !== null) {
-                    reformattedFilters = ["AND", queryFilters];
-                    break;
-                }
-            }
-
-            queryFilters = reformattedFilters;
-        }
-
-        return queryFilters;
-    },
-
-    canAddFilter(query) {
-        var queryFilters = Query.getFilters(query);
-        if (!queryFilters) {
+    canSortByAggregateField(query, index) {
+        if (!Query.hasValidBreakout(query)) {
             return false;
         }
-        if (queryFilters.length > 0) {
-            var lastFilter = queryFilters[queryFilters.length - 1];
-            // simply make sure that there are no null values in the last filter
-            for (var i=0; i < lastFilter.length; i++) {
-                if (lastFilter[i] === null) {
-                    return false;
-                }
-            }
-        }
-        return true;
-    },
-
-    addFilter(query) {
-        var queryFilters = Query.getFilters(query);
-
-        if (queryFilters.length === 0) {
-            queryFilters = ["AND", [null, null, null]];
-        } else {
-            queryFilters = queryFilters.concat([[null, null, null]]);
-        }
-
-        query.filter = queryFilters;
-    },
-
-    updateFilter(query, index, filter) {
-        var queryFilters = Query.getFilters(query);
-
-        queryFilters[index] = filter;
-
-        query.filter = queryFilters;
-    },
-
-    removeFilter(query, index) {
-        var queryFilters = Query.getFilters(query);
-
-        if (queryFilters.length === 2) {
-            // this equates to having a single filter because the arry looks like ... ["AND" [a filter def array]]
-            queryFilters = [];
-        } else {
-            queryFilters.splice(index, 1);
-        }
-
-        query.filter = queryFilters;
+        const aggregations = Query.getAggregations(query);
+        return (
+            aggregations[index] && aggregations[index][0] &&
+            SORTABLE_AGGREGATION_TYPES.has(mbql(aggregations[index][0]))
+        );
     },
 
     isSegmentFilter(filter) {
@@ -349,7 +221,7 @@ var Query = {
     },
 
     canAddLimitAndSort(query) {
-        if (Query.isBareRowsAggregation(query)) {
+        if (Query.isBareRows(query)) {
             return true;
         } else if (Query.hasValidBreakout(query)) {
             return true;
@@ -360,28 +232,30 @@ var Query = {
 
     getSortableFields(query, fields) {
         // in bare rows all fields are sortable, otherwise we only sort by our breakout columns
-
-        if (Query.isBareRowsAggregation(query)) {
+        if (Query.isBareRows(query)) {
             return fields;
         } else if (Query.hasValidBreakout(query)) {
             // further filter field list down to only fields in our breakout clause
             var breakoutFieldList = [];
 
-            query.breakout.map(function (breakoutField) {
-                let breakoutFieldId = Query.getFieldTargetId(breakoutField);
-                fields.map(function(field) {
-                    if (field.id === breakoutFieldId) {
-                        breakoutFieldList.push(field);
-                    }
-                });
+            const breakouts = Query.getBreakouts(query);
+            breakouts.map(function (breakoutField) {
+                const fieldId = Query.getFieldTargetId(breakoutField);
+                const field = _.findWhere(fields, { id: fieldId });
+                if (field) {
+                    breakoutFieldList.push(field);
+                }
             });
 
-            if (Query.canSortByAggregateField(query)) {
-                breakoutFieldList.push({
-                    id: ["aggregation",  0],
-                    name: query.aggregation[0], // e.g. "sum"
-                    display_name: query.aggregation[0]
-                });
+            const aggregations = Query.getAggregations(query);
+            for (const [index, aggregation] of aggregations.entries()) {
+                if (Query.canSortByAggregateField(query, index)) {
+                    breakoutFieldList.push({
+                        id: ["aggregation",  index],
+                        name: aggregation[0], // e.g. "sum"
+                        display_name: aggregation[0]
+                    });
+                }
             }
 
             return breakoutFieldList;
@@ -390,48 +264,11 @@ var Query = {
         }
     },
 
-    addLimit(query) {
-        query.limit = null;
-    },
-
-    updateLimit(query, limit) {
-        query.limit = limit;
-    },
-
-    removeLimit(query) {
-        delete query.limit;
-    },
-
     canAddSort(query) {
         // TODO: allow for multiple sorting choices
         return false;
     },
 
-    addSort(query) {
-        // TODO: make sure people don't try to sort by the same field multiple times
-        var order_by = query.order_by;
-        if (!order_by) {
-            order_by = [];
-        }
-
-        order_by.push([null, "ascending"]);
-        query.order_by = order_by;
-    },
-
-    updateSort(query, index, sort) {
-        query.order_by[index] = sort;
-    },
-
-    removeSort(query, index) {
-        if (query.order_by) {
-            if (query.order_by.length === 1) {
-                delete query.order_by;
-            } else {
-                query.order_by.splice(index, 1);
-            }
-        }
-    },
-
     getExpressions(query) {
         return query.expressions;
     },
@@ -474,23 +311,23 @@ var Query = {
     },
 
     isLocalField(field) {
-        return Array.isArray(field) && mbqlCompare(field[0], "field-id");
+        return Array.isArray(field) && mbqlEq(field[0], "field-id");
     },
 
     isForeignKeyField(field) {
-        return Array.isArray(field) && mbqlCompare(field[0], "fk->");
+        return Array.isArray(field) && mbqlEq(field[0], "fk->");
     },
 
     isDatetimeField(field) {
-        return Array.isArray(field) && mbqlCompare(field[0], "datetime-field");
+        return Array.isArray(field) && mbqlEq(field[0], "datetime-field");
     },
 
     isExpressionField(field) {
-        return Array.isArray(field) && field.length === 2 && mbqlCompare(field[0], "expression");
+        return Array.isArray(field) && field.length === 2 && mbqlEq(field[0], "expression");
     },
 
     isAggregateField(field) {
-        return Array.isArray(field) && mbqlCompare(field[0], "aggregation");
+        return Array.isArray(field) && mbqlEq(field[0], "aggregation");
     },
 
     isValidField(field) {
@@ -619,22 +456,25 @@ var Query = {
         return results;
     },
 
+    formatField(fieldDef, options = {}) {
+        let name = stripId(fieldDef && (fieldDef.display_name || fieldDef.name));
+        return name;
+    },
+
     getFieldName(tableMetadata, field, options) {
         try {
-            if (Query.isRegularField(field)) {
-                let fieldDef = Table.getField(tableMetadata, field);
-                if (fieldDef) {
-                    return fieldDef.display_name.replace(/\s+id\s*$/i, "");
+            let target = Query.getFieldTarget(field, tableMetadata);
+            let components = [];
+            if (target.path) {
+                for (const fieldDef of target.path) {
+                    components.push(Query.formatField(fieldDef, options), " → ");
                 }
-            } else if (Query.isForeignKeyField(field)) {
-                let fkFieldDef = Table.getField(tableMetadata, field[1]);
-                let targetTableDef = fkFieldDef && fkFieldDef.target.table;
-                return [Query.getFieldName(tableMetadata, field[1], options), " → ", Query.getFieldName(targetTableDef, field[2], options)];
-            } else if (Query.isDatetimeField(field)) {
-                return [Query.getFieldName(tableMetadata, field[1], options), " (" + field[3] + ")"];
-            } else if (Query.isExpressionField(field)) {
-                return field[1];
             }
+            components.push(Query.formatField(target.field, options));
+            if (target.unit) {
+                components.push(` (${target.unit})`)
+            }
+            return components;
         } catch (e) {
             console.warn("Couldn't format field name for field", field, "in table", tableMetadata);
         }
@@ -645,8 +485,8 @@ var Query = {
         return [inflection.pluralize(tableMetadata.display_name)];
     },
 
-    getAggregationDescription(tableMetadata, { aggregation }, options) {
-        if (aggregation) {
+    getAggregationDescription(tableMetadata, query, options) {
+        return conjunctList(Query.getAggregations(query).map(aggregation => {
             switch (aggregation[0]) {
                 case "METRIC":
                     let metric = _.findWhere(tableMetadata.metrics, { id: aggregation[1] });
@@ -662,9 +502,9 @@ var Query = {
                 case "cum_sum":   return     ["Cumulative sum of ", Query.getFieldName(tableMetadata, aggregation[1], options)];
                 case "max":       return            ["Maximum of ", Query.getFieldName(tableMetadata, aggregation[1], options)];
                 case "min":       return            ["Minimum of ", Query.getFieldName(tableMetadata, aggregation[1], options)];
+                default:          return [formatExpression(aggregation, { tableMetadata })]
             }
-        }
-        return "";
+        }), "and");
     },
 
     getBreakoutDescription(tableMetadata, { breakout }, options) {
@@ -674,8 +514,9 @@ var Query = {
     },
 
     getFilterDescription(tableMetadata, query, options) {
-        let filters = Query.getFilters(query);
-        if (filters && filters.length > 0) {
+        // getFilters returns list of filters without the implied "AND"
+        let filters = ["AND"].concat(Query.getFilters(query));
+        if (filters && filters.length > 1) {
             return ["Filtered by ", Query.getFilterClauseDescription(tableMetadata, filters, options)];
         }
     },
@@ -742,16 +583,12 @@ var Query = {
         return field && field[3];
     },
 
-    getAggregationType(query) {
-        return query && query.aggregation && query.aggregation[0];
-    },
-
-    getAggregationField(query) {
-        return query && query.aggregation && query.aggregation[1];
+    getAggregationType(aggregation) {
+        return aggregation && aggregation[0];
     },
 
-    getBreakouts(query) {
-        return (query && query.breakout) || [];
+    getAggregationField(aggregation) {
+        return aggregation && aggregation[1];
     },
 
     getQueryColumn(tableMetadata, field) {
@@ -765,22 +602,50 @@ var Query = {
 
     getQueryColumns(tableMetadata, query) {
         let columns = Query.getBreakouts(query).map(b => Query.getQueryColumn(tableMetadata, b));
-        const aggregation = Query.getAggregationType(query);
-        if (aggregation === "rows") {
+        if (Query.isBareRows(query)) {
             if (columns.length === 0) {
                 return null;
             }
         } else {
-            columns.push({
-                name: METRIC_NAME_BY_AGGREGATION[aggregation],
-                base_type: METRIC_TYPE_BY_AGGREGATION[aggregation],
-                special_type: TYPE.Number
-            });
+            for (const aggregation of Query.getAggregations(query)) {
+                const type = Query.getAggregationType(aggregation)
+                columns.push({
+                    name: METRIC_NAME_BY_AGGREGATION[type],
+                    base_type: METRIC_TYPE_BY_AGGREGATION[type],
+                    special_type: TYPE.Number
+                });
+            }
         }
         return columns;
     }
 }
 
+for (const prop in Q) {
+    Query[prop] = Q[prop];
+}
+
+import { isMath } from "metabase/lib/expressions";
+
+export const NamedClause = {
+    isNamed(clause) {
+        return Array.isArray(clause) && mbqlEq(clause[0], "named");
+    },
+    getName(clause) {
+        return NamedClause.isNamed(clause) ? clause[2] : null;
+    },
+    getContent(clause) {
+        return NamedClause.isNamed(clause) ? clause[1] : clause;
+    },
+    setName(clause, name) {
+        return ["named", NamedClause.getContent(clause), name];
+    },
+    setContent(clause, content) {
+        return NamedClause.isNamed(clause) ?
+            ["named", content, NamedClause.getName(clause)] :
+            content;
+    }
+}
+
 export const AggregationClause = {
 
     // predicate function to test if a given aggregation clause is fully formed
@@ -795,17 +660,21 @@ export const AggregationClause = {
 
     // predicate function to test if the given aggregation clause represents a Bare Rows aggregation
     isBareRows(aggregation) {
-        return AggregationClause.isValid(aggregation) && aggregation[0] === "rows";
+        return AggregationClause.isValid(aggregation) && mbqlEq(aggregation[0], "rows");
     },
 
     // predicate function to test if a given aggregation clause represents a standard aggregation
     isStandard(aggregation) {
-        return AggregationClause.isValid(aggregation) && aggregation[0] !== "METRIC";
+        return AggregationClause.isValid(aggregation) && !mbqlEq(aggregation[0], "metric");
+    },
+
+    getAggregation(aggregation) {
+        return aggregation && mbql(aggregation[0]);
     },
 
     // predicate function to test if a given aggregation clause represents a metric
     isMetric(aggregation) {
-        return AggregationClause.isValid(aggregation) && aggregation[0] === "METRIC";
+        return AggregationClause.isValid(aggregation) && mbqlEq(aggregation[0], "metric");
     },
 
     // get the metricId from a metric aggregation clause
@@ -817,9 +686,16 @@ export const AggregationClause = {
         }
     },
 
+    isCustom(aggregation) {
+        // for now treal all named clauses as custom
+        return aggregation && NamedClause.isNamed(aggregation) || isMath(aggregation) || (
+            AggregationClause.isStandard(aggregation) && _.any(aggregation.slice(1), (arg) => isMath(arg))
+        );
+    },
+
     // get the operator from a standard aggregation clause
     getOperator(aggregation) {
-        if (aggregation && aggregation.length > 0 && aggregation[0] !== "METRIC") {
+        if (aggregation && aggregation.length > 0 && !mbqlEq(aggregation[0], "metric")) {
             return aggregation[0];
         } else {
             return null;
@@ -828,7 +704,7 @@ export const AggregationClause = {
 
     // get the fieldId from a standard aggregation clause
     getField(aggregation) {
-        if (aggregation && aggregation.length > 1 && aggregation[0] !== "METRIC") {
+        if (aggregation && aggregation.length > 1 && !mbqlEq(aggregation[0], "metric")) {
             return aggregation[1];
         } else {
             return null;
@@ -849,25 +725,17 @@ export const AggregationClause = {
 export const BreakoutClause = {
 
     setBreakout(breakout, index, value) {
-        if (!breakout) return breakout;
-
-        if (breakout.length >= index+1) {
-            breakout[index] = value;
-            return breakout;
-
-        } else {
-            breakout.push(value);
-            return breakout;
+        if (!breakout) {
+            breakout = [];
         }
+        return [...breakout.slice(0,index), value, ...breakout.slice(index + 1)];
     },
 
     removeBreakout(breakout, index) {
-        if (breakout && breakout.length >= index+1) {
-            breakout.splice(index, 1);
-            return breakout;
-        } else {
-            return breakout;
+        if (!breakout) {
+            breakout = [];
         }
+        return [...breakout.slice(0,index), ...breakout.slice(index + 1)];
     }
 }
 
diff --git a/frontend/src/metabase/lib/query/aggregation.js b/frontend/src/metabase/lib/query/aggregation.js
new file mode 100644
index 0000000000000000000000000000000000000000..136c64853f3a2989922fad1510f91cc644153dbe
--- /dev/null
+++ b/frontend/src/metabase/lib/query/aggregation.js
@@ -0,0 +1,57 @@
+/* @flow */
+
+import { mbqlEq, noNullValues, add, update, remove, clear } from "./util";
+import _ from "underscore";
+
+import type { AggregationClause, Aggregation } from "metabase/meta/types/Query";
+
+// returns canonical list of Aggregations, i.e. with deprecated "rows" removed
+export function getAggregations(aggregation: ?AggregationClause): Aggregation[] {
+    let aggregations: Aggregation[];
+    if (Array.isArray(aggregation) && Array.isArray(aggregation[0])) {
+        aggregations = (aggregation: any);
+    } else if (Array.isArray(aggregation) && typeof aggregation[0] === "string") {
+        // legacy
+        aggregations = [(aggregation: any)];
+    } else {
+        aggregations = [];
+    }
+    return aggregations.filter(agg => agg && agg[0] && !mbqlEq(agg[0], "rows"));
+}
+
+// turns a list of Aggregations into the canonical AggregationClause
+function getAggregationClause(aggregations: Aggregation[]): ?AggregationClause {
+    aggregations = getAggregations(aggregations);
+    if (aggregations.length === 0) {
+        return undefined;
+    } else {
+        return aggregations;
+    }
+}
+
+export function addAggregation(aggregation: ?AggregationClause, newAggregation: Aggregation): ?AggregationClause {
+    return getAggregationClause(add(getAggregations(aggregation), newAggregation));
+}
+export function updateAggregation(aggregation: ?AggregationClause, index: number, updatedAggregation: Aggregation): ?AggregationClause {
+    return getAggregationClause(update(getAggregations(aggregation), index, updatedAggregation));
+}
+export function removeAggregation(aggregation: ?AggregationClause, index: number): ?AggregationClause {
+    return getAggregationClause(remove(getAggregations(aggregation), index));
+}
+export function clearAggregations(ac: ?AggregationClause): ?AggregationClause {
+    return getAggregationClause(clear());
+}
+
+// MISC
+
+export function isBareRows(ac: ?AggregationClause) {
+    return getAggregations(ac).length === 0;
+}
+
+export function hasEmptyAggregation(ac: ?AggregationClause): boolean {
+    return _.any(getAggregations(ac), (aggregation) => !noNullValues(aggregation));
+}
+
+export function hasValidAggregation(ac: ?AggregationClause): boolean {
+    return _.all(getAggregations(ac), (aggregation) => noNullValues(aggregation));
+}
diff --git a/frontend/src/metabase/lib/query/breakout.js b/frontend/src/metabase/lib/query/breakout.js
new file mode 100644
index 0000000000000000000000000000000000000000..8e7954e227b37974f0d45e8d077c4553f7f602ed
--- /dev/null
+++ b/frontend/src/metabase/lib/query/breakout.js
@@ -0,0 +1,33 @@
+/* @flow */
+
+import type { Breakout, BreakoutClause } from "metabase/meta/types/Query";
+
+import { add, update, remove, clear } from "./util";
+
+// returns canonical list of Breakouts, with nulls removed
+export function getBreakouts(breakout: ?BreakoutClause): Breakout[] {
+    return (breakout || []).filter(b => b != null);
+}
+
+// turns a list of Breakouts into the canonical BreakoutClause
+export function getBreakoutClause(breakouts: Breakout[]): ?BreakoutClause {
+    breakouts = getBreakouts(breakouts);
+    if (breakouts.length === 0) {
+        return undefined;
+    } else {
+        return breakouts;
+    }
+}
+
+export function addBreakout(breakout: ?BreakoutClause, newBreakout: Breakout): ?BreakoutClause {
+    return getBreakoutClause(add(getBreakouts(breakout), newBreakout));
+}
+export function updateBreakout(breakout: ?BreakoutClause, index: number, updatedBreakout: Breakout): ?BreakoutClause {
+    return getBreakoutClause(update(getBreakouts(breakout), index, updatedBreakout));
+}
+export function removeBreakout(breakout: ?BreakoutClause, index: number): ?BreakoutClause {
+    return getBreakoutClause(remove(getBreakouts(breakout), index));
+}
+export function clearBreakouts(breakout: ?BreakoutClause): ?BreakoutClause {
+    return getBreakoutClause(clear());
+}
diff --git a/frontend/src/metabase/lib/query/field.js b/frontend/src/metabase/lib/query/field.js
new file mode 100644
index 0000000000000000000000000000000000000000..2f4a76448205f7a14fab2f108de20163d9354902
--- /dev/null
+++ b/frontend/src/metabase/lib/query/field.js
@@ -0,0 +1,46 @@
+
+
+import { mbqlEq } from "./util";
+
+import type { Field } from "metabase/meta/types/Query";
+
+// gets the target field ID (recursively) from any type of field, including raw field ID, fk->, and datetime_field cast.
+export function getFieldTargetId(field: Field): ?FieldId {
+    if (isRegularField(field)) {
+        return field;
+    } else if (isLocalField(field)) {
+        // $FlowFixMe
+        return field[1];
+    } else if (isForeignKeyField(field)) {
+        // $FlowFixMe
+        return getFieldTargetId(field[2]);
+    } else if (isDatetimeField(field)) {
+        // $FlowFixMe
+        return getFieldTargetId(field[1]);
+    }
+    console.warn("Unknown field type: ", field);
+}
+
+export function isRegularField(field: Field): boolean {
+    return typeof field === "number";
+}
+
+export function isLocalField(field: Field): boolean {
+    return Array.isArray(field) && mbqlEq(field[0], "field-id");
+}
+
+export function isForeignKeyField(field: Field): boolean {
+    return Array.isArray(field) && mbqlEq(field[0], "fk->");
+}
+
+export function isDatetimeField(field: Field): boolean {
+    return Array.isArray(field) && mbqlEq(field[0], "datetime-field");
+}
+
+export function isExpressionField(field: Field): boolean {
+    return Array.isArray(field) && field.length === 2 && mbqlEq(field[0], "expression");
+}
+
+export function isAggregateField(field: Field): boolean {
+    return Array.isArray(field) && mbqlEq(field[0], "aggregation");
+}
diff --git a/frontend/src/metabase/lib/query/filter.js b/frontend/src/metabase/lib/query/filter.js
new file mode 100644
index 0000000000000000000000000000000000000000..c36a1629d064b1074eedf2cf2877c424910a308f
--- /dev/null
+++ b/frontend/src/metabase/lib/query/filter.js
@@ -0,0 +1,50 @@
+/* @flow */
+
+import { mbqlEq, op, args, noNullValues, add, update, remove, clear } from "./util";
+
+import type { FilterClause, Filter } from "metabase/meta/types/Query";
+
+// returns canonical list of Filters
+export function getFilters(filter: ?FilterClause): Filter[] {
+    if (!filter || Array.isArray(filter) && filter.length === 0) {
+        return [];
+    } else if (mbqlEq(op(filter), "and")) {
+        return args(filter);
+    } else {
+        return [filter];
+    }
+}
+
+// turns a list of Filters into the canonical FilterClause, either `undefined`, `filter`, or `["and", filter...]`
+function getFilterClause(filters: Filter[]): ?FilterClause {
+    if (filters.length === 0) {
+        return undefined;
+    } else if (filters.length === 1) {
+        return filters[0];
+    } else {
+        return (["and", ...filters]: any);
+    }
+}
+
+export function addFilter(filter: ?FilterClause, newFilter: FilterClause): ?FilterClause {
+    return getFilterClause(add(getFilters(filter), newFilter));
+}
+export function updateFilter(filter: ?FilterClause, index: number, updatedFilter: FilterClause): ?FilterClause {
+    return getFilterClause(update(getFilters(filter), index, updatedFilter));
+}
+export function removeFilter(filter: ?FilterClause, index: number): ?FilterClause {
+    return getFilterClause(remove(getFilters(filter), index));
+}
+export function clearFilters(filter: ?FilterClause): ?FilterClause {
+    return getFilterClause(clear());
+}
+
+// MISC
+
+export function canAddFilter(filter: ?FilterClause): boolean {
+    const filters = getFilters(filter);
+    if (filters.length > 0) {
+        return noNullValues(filters[filters.length - 1]);
+    }
+    return true;
+}
diff --git a/frontend/src/metabase/lib/query/limit.js b/frontend/src/metabase/lib/query/limit.js
new file mode 100644
index 0000000000000000000000000000000000000000..b6ac63f35c5ac15c8aadced2704fbc7be901bd31
--- /dev/null
+++ b/frontend/src/metabase/lib/query/limit.js
@@ -0,0 +1,11 @@
+/* @flow */
+
+import type { LimitClause } from "metabase/meta/types/Query";
+
+export function updateLimit(bc: ?LimitClause, limit: ?LimitClause): ?LimitClause {
+    return limit;
+}
+
+export function clearLimit(bc: ?LimitClause): ?LimitClause {
+    return undefined;
+}
diff --git a/frontend/src/metabase/lib/query/order_by.js b/frontend/src/metabase/lib/query/order_by.js
new file mode 100644
index 0000000000000000000000000000000000000000..78e5b64e74ca55c6e4c8cfffb73423e8e5737677
--- /dev/null
+++ b/frontend/src/metabase/lib/query/order_by.js
@@ -0,0 +1,33 @@
+/* @flow */
+
+import type { OrderBy, OrderByClause } from "metabase/meta/types/Query";
+
+import { add, update, remove, clear } from "./util";
+
+// returns canonical list of OrderBys, with nulls removed
+export function getOrderBys(breakout: ?OrderByClause): OrderBy[] {
+    return (breakout || []).filter(b => b != null);
+}
+
+// turns a list of OrderBys into the canonical OrderByClause
+export function getOrderByClause(breakouts: OrderBy[]): ?OrderByClause {
+    breakouts = getOrderBys(breakouts);
+    if (breakouts.length === 0) {
+        return undefined;
+    } else {
+        return breakouts;
+    }
+}
+
+export function addOrderBy(breakout: ?OrderByClause, newOrderBy: OrderBy): ?OrderByClause {
+    return getOrderByClause(add(getOrderBys(breakout), newOrderBy));
+}
+export function updateOrderBy(breakout: ?OrderByClause, index: number, updatedOrderBy: OrderBy): ?OrderByClause {
+    return getOrderByClause(update(getOrderBys(breakout), index, updatedOrderBy));
+}
+export function removeOrderBy(breakout: ?OrderByClause, index: number): ?OrderByClause {
+    return getOrderByClause(remove(getOrderBys(breakout), index));
+}
+export function clearOrderBy(breakout: ?OrderByClause): ?OrderByClause {
+    return getOrderByClause(clear());
+}
diff --git a/frontend/src/metabase/lib/query/query.js b/frontend/src/metabase/lib/query/query.js
new file mode 100644
index 0000000000000000000000000000000000000000..de1973c40c5c55d7fa5196d1eb6495a0b2a692c4
--- /dev/null
+++ b/frontend/src/metabase/lib/query/query.js
@@ -0,0 +1,108 @@
+/* @flow */
+
+import type {
+    StructuredQuery as SQ,
+    Aggregation, AggregationClause,
+    Breakout, BreakoutClause,
+    Filter, FilterClause,
+    LimitClause,
+    OrderBy, OrderByClause
+} from "metabase/meta/types/Query";
+
+import * as A from "./aggregation";
+import * as B from "./breakout";
+import * as F from "./filter";
+import * as L from "./limit";
+import * as O from "./order_by";
+
+import Query from "metabase/lib/query";
+import _ from "underscore";
+
+// AGGREGATION
+
+export const getAggregations     = (query: SQ)                                          => A.getAggregations(query.aggregation);
+export const addAggregation      = (query: SQ, aggregation: Aggregation)                => setAggregationClause(query, A.addAggregation(query.aggregation, aggregation));
+export const updateAggregation   = (query: SQ, index: number, aggregation: Aggregation) => setAggregationClause(query, A.updateAggregation(query.aggregation, index, aggregation));
+export const removeAggregation   = (query: SQ, index: number)                           => setAggregationClause(query, A.removeAggregation(query.aggregation, index));
+export const clearAggregations   = (query: SQ)                                          => setAggregationClause(query, A.clearAggregations(query.aggregation));
+
+export const isBareRows          = (query: SQ) => A.isBareRows(query.aggregation);
+export const hasEmptyAggregation = (query: SQ) => A.hasEmptyAggregation(query.aggregation);
+export const hasValidAggregation = (query: SQ) => A.hasValidAggregation(query.aggregation);
+
+// BREAKOUT
+
+export const getBreakouts   = (query: SQ)                                    => B.getBreakouts(query.breakout);
+export const addBreakout    = (query: SQ, breakout: Breakout)                => setBreakoutClause(query, B.addBreakout(query.breakout, breakout));
+export const updateBreakout = (query: SQ, index: number, breakout: Breakout) => setBreakoutClause(query, B.updateBreakout(query.breakout, index, breakout));
+export const removeBreakout = (query: SQ, index: number)                     => setBreakoutClause(query, B.removeBreakout(query.breakout, index));
+export const clearBreakouts = (query: SQ)                                    => setBreakoutClause(query, B.clearBreakouts(query.breakout));
+
+// FILTER
+
+export const getFilters   = (query: SQ)                                 => F.getFilters(query.filter);
+export const addFilter    = (query: SQ, filter: Filter)                 => setFilterClause(query, F.addFilter(query.filter, filter));
+export const updateFilter = (query: SQ, index: number, filter: Filter)  => setFilterClause(query, F.updateFilter(query.filter, index, filter));
+export const removeFilter = (query: SQ, index: number)                  => setFilterClause(query, F.removeFilter(query.filter, index));
+export const clearFilters = (query: SQ)                                 => setFilterClause(query, F.clearFilters(query.filter));
+
+export const canAddFilter = (query: SQ) => F.canAddFilter(query.filter);
+
+// ORDER_BY
+
+export const getOrderBys   = (query: SQ)                                   => O.getOrderBys(query.order_by);
+export const addOrderBy    = (query: SQ, order_by: OrderBy)                => setOrderByClause(query, O.addOrderBy(query.order_by, order_by));
+export const updateOrderBy = (query: SQ, index: number, order_by: OrderBy) => setOrderByClause(query, O.updateOrderBy(query.order_by, index, order_by));
+export const removeOrderBy = (query: SQ, index: number)                    => setOrderByClause(query, O.removeOrderBy(query.order_by, index));
+export const clearOrderBy  = (query: SQ)                                   => setOrderByClause(query, O.clearOrderBy(query.order_by));
+
+// LIMIT
+
+export const updateLimit = (query: SQ, limit: LimitClause) => setLimitClause(query, L.updateLimit(query.limit, limit));
+export const clearLimit = (query: SQ) => setLimitClause(query, L.clearLimit(query.limit));
+
+// we can enforce various constraints in these functions:
+
+function setAggregationClause(query: SQ, aggregationClause: ?AggregationClause): SQ {
+    let wasBareRows = A.isBareRows(query.aggregation);
+    let isBareRows = A.isBareRows(aggregationClause);
+    // when switching to or from bare rows clear out any sorting clauses
+    if (isBareRows !== wasBareRows) {
+        clearOrderBy(query);
+    }
+    // for bare rows we always clear out any dimensions because they don't make sense
+    if (isBareRows) {
+        clearBreakouts(query);
+    }
+    return setClause("aggregation", query, aggregationClause);
+}
+function setBreakoutClause(query: SQ, breakoutClause: ?BreakoutClause): SQ {
+    let breakoutIds = B.getBreakouts(breakoutClause).filter(id => id != null);
+    for (const [index, sort] of getOrderBys(query).entries()) {
+        let sortId = Query.getFieldTargetId(sort[0]);
+        if (sortId != null && !_.contains(breakoutIds, sortId)) {
+            query = removeOrderBy(query, index);
+        }
+    }
+    return setClause("breakout", query, breakoutClause);
+}
+function setFilterClause(query: SQ, filterClause: ?FilterClause): SQ {
+    return setClause("filter", query, filterClause);
+}
+function setOrderByClause(query: SQ, orderByClause: ?OrderByClause): SQ {
+    return setClause("order_by", query, orderByClause);
+}
+function setLimitClause(query: SQ, limitClause: ?LimitClause): SQ {
+    return setClause("limit", query, limitClause);
+}
+
+// TODO: remove mutation
+type FilterClauseName = "filter"|"aggregation"|"breakout"|"order_by"|"limit";
+function setClause(clauseName: FilterClauseName, query: SQ, clause: ?any): SQ {
+    if (clause == null) {
+        delete query[clauseName];
+    } else {
+        query[clauseName] = clause
+    }
+    return query;
+}
diff --git a/frontend/src/metabase/lib/query/util.js b/frontend/src/metabase/lib/query/util.js
new file mode 100644
index 0000000000000000000000000000000000000000..5c80c8130875e8d011f871ad34a773eac309f93c
--- /dev/null
+++ b/frontend/src/metabase/lib/query/util.js
@@ -0,0 +1,27 @@
+/* @flow */
+
+import _ from "underscore";
+
+export const mbql = (a: string):string =>
+    typeof a === "string" ? a.toLowerCase().replace(/_/g, "-") : a;
+
+export const mbqlEq = (a: string, b: string): boolean =>
+    mbql(a) === mbql(b);
+
+export const noNullValues = (clause: any[]): boolean =>
+    _.all(clause, c => c != null);
+
+// these are mostly to circumvent Flow type checking :-/
+export const op = (clause: any): string =>
+    clause[0];
+export const args = (clause: any[]): any[] =>
+    clause.slice(1);
+
+export const add = <T>(items: T[], item: T): T[] =>
+    [...items, item];
+export const update = <T>(items: T[], index: number, newItem: T): T[] =>
+    [...items.slice(0, index), newItem, ...items.slice(index + 1)];
+export const remove = <T>(items: T[], index: number): T[] =>
+    [...items.slice(0, index), ...items.slice(index + 1)];
+export const clear = <T>(): T[] =>
+    [];
diff --git a/frontend/src/metabase/lib/redux.js b/frontend/src/metabase/lib/redux.js
index 0d04daac373851d8dbe6ed2b20909d0b3ca9262c..5ef82fd9b2cb82276bbc5cbb9d73a25eebec8948 100644
--- a/frontend/src/metabase/lib/redux.js
+++ b/frontend/src/metabase/lib/redux.js
@@ -63,6 +63,10 @@ export function momentifyObjectsTimestamps(objects, keys) {
     return _.mapObject(objects, o => momentifyTimestamps(o, keys));
 }
 
+export function momentifyArraysTimestamps(array, keys) {
+    return _.map(array, o => momentifyTimestamps(o, keys));
+}
+
 // turns into id indexed map
 export const resourceListToMap = (resources) =>
     resources.reduce((map, resource) => ({ ...map, [resource.id]: resource }), {});
diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js
index ee2bdb526ae1bddd779322203ff3068bde3a6be3..d2c6c24c89d650abd737bb177ff6867bc52ee34b 100644
--- a/frontend/src/metabase/lib/urls.js
+++ b/frontend/src/metabase/lib/urls.js
@@ -34,6 +34,14 @@ var Urls = {
         }
 
         return url;
+    },
+
+    collection(collection) {
+        return `/questions/collections/${encodeURIComponent(collection.slug)}`;
+    },
+
+    label(label) {
+        return `/questions/search?label=${encodeURIComponent(label.slug)}`;
     }
 }
 
diff --git a/frontend/src/metabase/lib/visualization_settings.js b/frontend/src/metabase/lib/visualization_settings.js
index fb2485b4566f3ef5fc1105d5a4be7547a8e1bc16..a5a4a404e43a2e8cab228fec3053dc619069007e 100644
--- a/frontend/src/metabase/lib/visualization_settings.js
+++ b/frontend/src/metabase/lib/visualization_settings.js
@@ -531,7 +531,8 @@ const SETTINGS = {
         getDefault: ([{ card, data }]) => (
             (data && data.cols.length === 3) &&
             Query.isStructured(card.dataset_query) &&
-            !Query.isBareRowsAggregation(card.dataset_query.query)
+            data.cols.filter(isMetric).length === 1 &&
+            data.cols.filter(isDimension).length === 2
         )
     },
     "table.columns": {
diff --git a/frontend/src/metabase/meta/types/Query.js b/frontend/src/metabase/meta/types/Query.js
index be77a68c43fb30d3a3970384e31e7fe4aee70d7f..4509791718066a2c576279caed60e8762907b2d4 100644
--- a/frontend/src/metabase/meta/types/Query.js
+++ b/frontend/src/metabase/meta/types/Query.js
@@ -38,29 +38,85 @@ export type StructuredQuery = {
     filter?:      FilterClause,
     order_by?:    OrderByClause,
     limit?:       LimitClause,
-    expressions?: { [key: ExpressionName]: Expression },
-    fields?:      FieldsClause
+    expressions?: ExpressionClause,
+    fields?:      FieldsClause,
 };
 
 export type AggregationClause =
-    ["rows"] | // deprecated
-    ["count"] |
-    ["count"|"avg"|"cum_sum"|"distinct"|"stddev"|"sum"|"min"|"max", ConcreteField] |
-    ["metric", MetricId];
-
-export type BreakoutClause = Array<ConcreteField>;
-export type FilterClause =
-    ["and"|"or",            FilterClause, FilterClause] |
-    ["not",                 FilterClause] |
-    ["="|"!=",              ConcreteField, Value] |
-    ["<"|">"|"<="|">=",     ConcreteField, OrderableValue] |
-    ["is-null"|"not-null",  ConcreteField] |
-    ["between",             ConcreteField, OrderableValue, OrderableValue] |
-    ["inside",              ConcreteField, ConcreteField, NumericLiteral, NumericLiteral, NumericLiteral, NumericLiteral] |
-    ["starts-with"|"contains"|"does-not-contain"|"ends-with",
-                            ConcreteField, StringLiteral] |
-    ["time-interval",       ConcreteField, RelativeDatetimePeriod, RelativeDatetimeUnit] |
-    ["segment",             SegmentId];
+    Aggregation | // deprecated
+    Array<Aggregation>;
+
+export type Aggregation =
+    Rows | // deprecated
+    CountAgg |
+    CountFieldAgg |
+    AvgAgg |
+    CumSumAgg |
+    DistinctAgg |
+    StdDevAgg |
+    SumAgg |
+    MinAgg |
+    MaxAgg |
+    MetricAgg;
+
+type Rows           = ["rows"]; // deprecated
+type CountAgg       = ["count"];
+type CountFieldAgg  = ["count", ConcreteField];
+type AvgAgg         = ["avg", ConcreteField];
+type CumSumAgg      = ["cum_sum", ConcreteField];
+type DistinctAgg    = ["distinct", ConcreteField];
+type StdDevAgg      = ["stddev", ConcreteField];
+type SumAgg         = ["sum", ConcreteField];
+type MinAgg         = ["min", ConcreteField];
+type MaxAgg         = ["max", ConcreteField];
+type MetricAgg      = ["metric", MetricId];
+
+export type BreakoutClause = Array<Breakout>;
+export type Breakout =
+    ConcreteField;
+
+export type FilterClause = Filter;
+export type Filter =
+    AndFilter          |
+    OrFilter           |
+    NotFilter          |
+    EqualFilter        |
+    NEFilter           |
+    LTFilter           |
+    LTEFilter          |
+    GTFilter           |
+    GTEFilter          |
+    NullFilter         |
+    NotNullFilter      |
+    NotNullFilter      |
+    BetweenFilter      |
+    InsideFilter       |
+    StartsWithFilter   |
+    ContainsFilter     |
+    NotContainsFilter  |
+    EndsWithFilter     |
+    TimeIntervalFilter |
+    SegmentFilter;
+
+type AndFilter          = ["and", Filter, Filter];
+type OrFilter           = ["or", Filter, Filter];
+type NotFilter          = ["not", Filter];
+type EqualFilter        = ["=", ConcreteField, Value];
+type NEFilter           = ["!=", ConcreteField, Value];
+type LTFilter           = ["<", ConcreteField, OrderableValue];
+type LTEFilter          = ["<=", ConcreteField, OrderableValue];
+type GTFilter           = [">", ConcreteField, OrderableValue];
+type GTEFilter          = [">=", ConcreteField, OrderableValue];
+type NullFilter         = ["is-null", ConcreteField];
+type NotNullFilter      = ["not-null", ConcreteField];
+type BetweenFilter      = ["between", ConcreteField, OrderableValue, OrderableValue];
+type InsideFilter       = ["inside", ConcreteField, ConcreteField, NumericLiteral, NumericLiteral, NumericLiteral, NumericLiteral];
+type StartsWithFilter   = ["starts-with", ConcreteField, StringLiteral];
+type ContainsFilter     = ["contains", ConcreteField, StringLiteral];
+type NotContainsFilter  = ["does-not-contain", ConcreteField, StringLiteral];
+type EndsWithFilter     = ["ends-with", ConcreteField, StringLiteral];
+type TimeIntervalFilter = ["time-interval", ConcreteField, RelativeDatetimePeriod, RelativeDatetimeUnit];
+type SegmentFilter      = ["segment", SegmentId];
 
 export type OrderByClause = Array<OrderBy>;
 export type OrderBy = ["asc"|"desc", Field];
@@ -93,10 +149,15 @@ export type DatetimeField =
 
 export type AggregateField = ["aggregation", number];
 
-export type ExpressionOperator = "+" | "-" | "*" | "/";
-export type ExpressionOperand = ConcreteField | NumericLiteral | Expression;
+
+export type ExpressionClause = {
+    [key: ExpressionName]: Expression
+};
 
 export type Expression =
     [ExpressionOperator, ExpressionOperand, ExpressionOperand];
 
+export type ExpressionOperator = "+" | "-" | "*" | "/";
+export type ExpressionOperand = ConcreteField | NumericLiteral | Expression;
+
 export type FieldsClause = FieldId[];
diff --git a/frontend/src/metabase/nav/components/ProfileLink.jsx b/frontend/src/metabase/nav/components/ProfileLink.jsx
index 6192eb174db1941b74a4dcab8a7c905369bfa1a6..a244dc74eeef0f54d59301c51cc099b9a1f0bac3 100644
--- a/frontend/src/metabase/nav/components/ProfileLink.jsx
+++ b/frontend/src/metabase/nav/components/ProfileLink.jsx
@@ -133,7 +133,7 @@ export default class ProfileLink extends Component {
                 : null }
 
                 { modalOpen === "about" ?
-                    <Modal className="Modal Modal--small" onClose={this.closeModal}>
+                    <Modal small onClose={this.closeModal}>
                         <div className="px4 pt4 pb2 text-centered relative">
                             <span className="absolute top right p4 text-normal text-grey-3 cursor-pointer" onClick={this.closeModal}>
                                 <Icon name={'close'} size={16} />
@@ -160,7 +160,7 @@ export default class ProfileLink extends Component {
                         </div>
                     </Modal>
                 : modalOpen === "logs" ?
-                    <Modal className="Modal Modal--wide" onClose={this.closeModal}>
+                    <Modal wide onClose={this.closeModal}>
                         <Logs onClose={this.closeModal} />
                     </Modal>
                 : null }
diff --git a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx
index e01f7d0852c017b8910aa8f6712e8c4e3ebe418b..69567f7867f58cdaa82062f388d44c083c8e89dc 100644
--- a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx
+++ b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx
@@ -102,7 +102,7 @@ export default class DashboardsDropdown extends Component {
             <Modal>
                 <CreateDashboardModal
                     createDashboardFn={this.onCreateDashboard.bind(this)}
-                    closeFn={this.closeModal} />
+                    onClose={this.closeModal} />
             </Modal>
         );
     }
diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx
index 36459efe3ed45c632fd0e160bc0a3e150d64c116..61170f1a24c71b975448ca7fd342a17864adb24b 100644
--- a/frontend/src/metabase/nav/containers/Navbar.jsx
+++ b/frontend/src/metabase/nav/containers/Navbar.jsx
@@ -73,10 +73,10 @@ export default class Navbar extends Component {
                 <div className="wrapper flex align-center">
                     <div className="NavTitle flex align-center">
                         <Icon name={'gear'} className="AdminGear" size={22}></Icon>
-                        <span className="NavItem-text ml1 hide sm-show">Site Administration</span>
+                        <span className="NavItem-text ml1 hide sm-show text-bold">Metabase Admin Panel</span>
                     </div>
 
-                    <ul className="sm-ml4 flex flex-full">
+                    <ul className="sm-ml4 flex flex-full text-strong">
                         <AdminNavItem name="Settings"    path="/admin/settings"     currentPath={this.props.path} />
                         <AdminNavItem name="People"      path="/admin/people"       currentPath={this.props.path} />
                         <AdminNavItem name="Data Model"  path="/admin/datamodel"    currentPath={this.props.path} />
@@ -124,7 +124,7 @@ export default class Navbar extends Component {
                         </DashboardsDropdown>
                     </li>
                     <li className="pl1">
-                        <Link to="/questions/all" data-metabase-event={"Navbar;Questions"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Questions</Link>
+                        <Link to="/questions" data-metabase-event={"Navbar;Questions"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Questions</Link>
                     </li>
                     <li className="pl1">
                         <Link to="/pulse" data-metabase-event={"Navbar;Pulses"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Pulses</Link>
diff --git a/frontend/src/metabase/nav/selectors.js b/frontend/src/metabase/nav/selectors.js
index 612ad051c2626f8088c8f305db5664cf34c6ab0a..72255cc72213ffb23e74011d5541c1fb9aad2c76 100644
--- a/frontend/src/metabase/nav/selectors.js
+++ b/frontend/src/metabase/nav/selectors.js
@@ -1,9 +1,9 @@
 
 import { createSelector } from 'reselect';
 
-export const getPath = (state, props) => props.location.pathname;
+export { getUser } from "metabase/selectors/user";
 
-export const getUser = (state) => state.currentUser;
+export const getPath = (state, props) => props.location.pathname;
 
 export const getContext = createSelector(
     [getPath],
diff --git a/frontend/src/metabase/pulse/components/CardPicker.jsx b/frontend/src/metabase/pulse/components/CardPicker.jsx
index ce6b885c093ae109721f1d66eef5ae8c20e8570d..9a0069980853c17f5fe10fbcd283f4eea6af0f9d 100644
--- a/frontend/src/metabase/pulse/components/CardPicker.jsx
+++ b/frontend/src/metabase/pulse/components/CardPicker.jsx
@@ -2,7 +2,9 @@
 import React, { Component, PropTypes } from "react";
 import ReactDOM from "react-dom";
 
+import Icon from "metabase/components/Icon.jsx";
 import Popover from "metabase/components/Popover.jsx";
+import Query from "metabase/lib/query";
 
 import _ from "underscore";
 
@@ -13,7 +15,8 @@ export default class CardPicker extends Component {
         this.state = {
             isOpen: false,
             inputValue: "",
-            inputWidth: 300
+            inputWidth: 300,
+            collectionId: undefined,
         };
 
         _.bindAll(this, "onChange", "onInputChange", "onInputFocus", "onInputBlur");
@@ -41,18 +44,24 @@ export default class CardPicker extends Component {
         // which causes the click handler to not fire. For some reason this even
         // happens with a 100ms delay, but not 200ms?
         clearTimeout(this._timer);
-        this._timer = setTimeout(() => this.setState({ isOpen: false }), 250);
+        this._timer = setTimeout(() => {
+            if (!this.state.isClicking) {
+                this.setState({ isOpen: false })
+            } else {
+                this.setState({ isClicking: false })
+            }
+        }, 250);
     }
 
     onChange(id) {
         this.props.onChange(id);
-        this.setState({ isOpen: false });
+        ReactDOM.findDOMNode(this.refs.input).blur();
     }
 
     renderItem(card) {
         let error;
         try {
-            if (card.dataset_query.query.aggregation[0] === "rows") {
+            if (Query.isBareRows(card.dataset_query.query)) {
                 error = "Raw data cannot be included in pulses";
             }
         } catch (e) {}
@@ -86,21 +95,42 @@ export default class CardPicker extends Component {
 
     render() {
         let { cardList } = this.props;
-        let { isOpen, inputValue, inputWidth } = this.state;
 
+        let { isOpen, inputValue, inputWidth, collectionId } = this.state;
+
+        let cardByCollectionId = _.groupBy(cardList, "collection_id");
+        let collectionIds = Object.keys(cardByCollectionId);
+
+        const collections = _.chain(cardList)
+            .map(card => card.collection)
+            .uniq(c => c && c.id)
+            .filter(c => c)
+            .sortBy("name")
+            .value();
+
+        collections.unshift({ id: null, name: "None" });
+
+        let visibleCardList;
         if (inputValue) {
             let searchString = inputValue.toLowerCase();
-            cardList = cardList.filter((card) =>
+            visibleCardList = cardList.filter((card) =>
                 ~(card.name || "").toLowerCase().indexOf(searchString) ||
                 ~(card.description || "").toLowerCase().indexOf(searchString)
             );
+        } else {
+            if (collectionId !== undefined) {
+                visibleCardList = cardByCollectionId[collectionId];
+            } else if (collectionIds.length === 1) {
+                visibleCardList = cardByCollectionId[collectionIds[0]];
+            }
         }
 
+        const collection = _.findWhere(collections, { id: collectionId });
         return (
             <div className="CardPicker flex-full">
                 <input
                     ref="input"
-                    className="input no-focus full h4 text-bold"
+                    className="input no-focus full text-bold"
                     placeholder="Type a question name to filter"
                     value={this.inputValue}
                     onFocus={this.onInputFocus}
@@ -116,11 +146,52 @@ export default class CardPicker extends Component {
                         targetOffset: "0 0"
                     }}
                 >
-                    <ul className="List rounded bordered text-brand scroll-y scroll-show" style={{ width: inputWidth + "px", maxHeight: "400px" }}>
-                        {cardList.map((card) => this.renderItem(card))}
-                    </ul>
+                    <div className="rounded bordered scroll-y scroll-show" style={{ width: inputWidth + "px", maxHeight: "400px" }}>
+                    { visibleCardList && collectionIds.length > 1 &&
+                        <div className="flex align-center text-slate cursor-pointer border-bottom p2"  onClick={(e) => {
+                            this.setState({ collectionId: undefined, isClicking: true });
+                        }}>
+                            <Icon name="chevronleft" size={18} />
+                            <h3 className="ml1">{collection && collection.name}</h3>
+                        </div>
+                    }
+                    { visibleCardList ?
+                        <ul className="List text-brand">
+                            {visibleCardList.map((card) => this.renderItem(card))}
+                        </ul>
+                    : collections ?
+                        <CollectionList>
+                        {collections.map(collection =>
+                            <CollectionListItem collection={collection} onClick={(e) => {
+                                this.setState({ collectionId: collection.id, isClicking: true });
+                            }}/>
+                        )}
+                        </CollectionList>
+                    : null }
+                    </div>
                 </Popover>
             </div>
         );
     }
 }
+
+const CollectionListItem = ({ collection, onClick }) =>
+    <li className="List-item cursor-pointer flex align-center py1 px2" onClick={onClick}>
+        <Icon name="collection" style={{ color: collection.color }} className="Icon mr2 text-default" size={18} />
+        <h4 className="List-item-title">{collection.name}</h4>
+        <Icon name="chevronright" className="flex-align-right text-grey-2" />
+    </li>
+
+CollectionListItem.propTypes = {
+    collection: PropTypes.object.isRequired,
+    onClick: PropTypes.func.isRequired
+};
+
+const CollectionList = ({ children }) =>
+    <ul className="List text-brand">
+        {children}
+    </ul>
+
+CollectionList.propTypes = {
+    children: PropTypes.array.isRequired
+};
diff --git a/frontend/src/metabase/pulse/components/PulseEdit.jsx b/frontend/src/metabase/pulse/components/PulseEdit.jsx
index 93423110e612c7254580486e5b0cfa298aeb0a02..a76852c8c9ac4283dde35fcceb94b8315f694c97 100644
--- a/frontend/src/metabase/pulse/components/PulseEdit.jsx
+++ b/frontend/src/metabase/pulse/components/PulseEdit.jsx
@@ -102,7 +102,7 @@ export default class PulseEdit extends Component {
                         triggerClasses="text-brand text-bold flex-align-right"
                     >
                         <ModalContent
-                            closeFn={() => this.refs.pulseInfo.close()}
+                            onClose={() => this.refs.pulseInfo.close()}
                         >
                             <div className="mx4 mb4">
                                 <WhatsAPulse
diff --git a/frontend/src/metabase/pulse/components/PulseEditName.jsx b/frontend/src/metabase/pulse/components/PulseEditName.jsx
index dcb9e3a4cc0105443024cac9c45acae8505f679a..c3c0cbacecf4af47b203cea549a8df20981d0c98 100644
--- a/frontend/src/metabase/pulse/components/PulseEditName.jsx
+++ b/frontend/src/metabase/pulse/components/PulseEditName.jsx
@@ -36,7 +36,7 @@ export default class PulseEditName extends Component {
                 <div className="my3">
                     <input
                         ref="name"
-                        className={cx("input h4 text-bold text-default", { "border-error": !this.state.valid })}
+                        className={cx("input text-bold", { "border-error": !this.state.valid })}
                         style={{"width":"400px"}}
                         value={pulse.name || ""}
                         onChange={this.setName}
diff --git a/frontend/src/metabase/pulse/components/SetupModal.jsx b/frontend/src/metabase/pulse/components/SetupModal.jsx
index a9bf6d240fd3499d7b853f149030bd8969fb50d0..a00caf4d1f1a2723f891643d4da02dc33ca6eeb4 100644
--- a/frontend/src/metabase/pulse/components/SetupModal.jsx
+++ b/frontend/src/metabase/pulse/components/SetupModal.jsx
@@ -14,7 +14,7 @@ export default class SetupModal extends Component {
     render() {
         return (
             <ModalContent
-                closeFn={this.props.onClose}
+                onClose={this.props.onClose}
             >
                 <div className="mx4 px4 pb4 text-centered">
                     <h2>To send pulses, an admin needs to set up email or Slack integration.</h2>
diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index f7d2fe2f39fd36fce69c1ae7e00741a400e64c05..9836969e7d284bfd063744e53a22a907dce8bfca 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -904,7 +904,6 @@ export const cellClicked = createThunkAction(CELL_CLICKED, (rowIndex, columnInde
         } else {
             // this is applying a filter by clicking on a cell value
             let dataset_query = JSON.parse(JSON.stringify(card.dataset_query));
-            Query.addFilter(dataset_query.query);
 
             if (coldef.unit && coldef.unit != "default" && filter === "=") {
                 // this is someone using quick filters on a datetime value
@@ -917,11 +916,10 @@ export const cellClicked = createThunkAction(CELL_CLICKED, (rowIndex, columnInde
                     case "year": start = moment(value, "YYYY").format("YYYY-MM-DD");
                                  end = moment(value, "YYYY").add(1, "years").subtract(1, "days").format("YYYY-MM-DD"); break;
                 }
-                Query.updateFilter(dataset_query.query, dataset_query.query.filter.length - 1, ["BETWEEN", fieldRefForm, start, end]);
-
+                Query.addFilter(dataset_query.query, ["BETWEEN", fieldRefForm, start, end]);
             } else {
                 // quick filtering on a normal value (string/number)
-                Query.updateFilter(dataset_query.query, dataset_query.query.filter.length - 1, [filter, fieldRefForm, value]);
+                Query.addFilter(dataset_query.query, [filter, fieldRefForm, value]);
             }
 
             // update and run the query
diff --git a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx
index a56f3ba651687609487003227402cabd659c5fd5..f6467d106411f21c5b6df2f87bac0e5780e46aed 100644
--- a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx
+++ b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx
@@ -6,12 +6,16 @@ import QueryDefinitionTooltip from "./QueryDefinitionTooltip.jsx";
 
 import Icon from "metabase/components/Icon.jsx";
 import Tooltip from "metabase/components/Tooltip.jsx";
+import Button from "metabase/components/Button.jsx";
 
 import Query from "metabase/lib/query";
-import { AggregationClause } from "metabase/lib/query";
+import { AggregationClause, NamedClause } from "metabase/lib/query";
 
 import _ from "underscore";
 
+import ExpressionEditorTextfield from "./expressions/ExpressionEditorTextfield.jsx"
+
+const CUSTOM_SECTION_NAME = "Custom Expression";
 
 export default class AggregationPopover extends Component {
     constructor(props, context) {
@@ -19,7 +23,8 @@ export default class AggregationPopover extends Component {
 
         this.state = {
             aggregation: (props.isNew ? [] : props.aggregation),
-            choosingField: (props.aggregation && props.aggregation.length > 1 && AggregationClause.isStandard(props.aggregation))
+            choosingField: (props.aggregation && props.aggregation.length > 1 && AggregationClause.isStandard(props.aggregation)),
+            editingAggregation: (props.aggregation && props.aggregation.length > 1 && AggregationClause.isCustom(props.aggregation))
         };
 
         _.bindAll(this, "commitAggregation", "onPickAggregation", "onPickField", "onClearAggregation");
@@ -43,7 +48,12 @@ export default class AggregationPopover extends Component {
 
     onPickAggregation(agg) {
         // check if this aggregation requires a field, if so then force user to pick that now, otherwise we are done
-        if (agg.aggregation && agg.aggregation.requiresField) {
+        if (agg.custom) {
+            this.setState({
+                aggregation: agg.value,
+                editingAggregation: true
+            });
+        } else if (agg.aggregation && agg.aggregation.requiresField) {
             this.setState({
                 aggregation: agg.value,
                 choosingField: true
@@ -60,7 +70,8 @@ export default class AggregationPopover extends Component {
 
     onClearAggregation() {
         this.setState({
-            choosingField: false
+            choosingField: false,
+            editingAggregation: false
         });
     }
 
@@ -74,7 +85,7 @@ export default class AggregationPopover extends Component {
 
     itemIsSelected(item) {
         const { aggregation } = this.props;
-        return (aggregation[0] === item.value[0] && (aggregation[0] !== "METRIC" || aggregation[1] === item.value[1]));
+        return item.isSelected(NamedClause.getContent(aggregation));
     }
 
     renderItemExtra(item, itemIndex) {
@@ -104,7 +115,8 @@ export default class AggregationPopover extends Component {
 
     render() {
         const { availableAggregations, tableMetadata } = this.props;
-        const { aggregation, choosingField } = this.state;
+        const { choosingField, editingAggregation } = this.state;
+        const aggregation = NamedClause.getContent(this.state.aggregation);
 
         let selectedAggregation;
         if (AggregationClause.isMetric(aggregation)) {
@@ -121,6 +133,7 @@ export default class AggregationPopover extends Component {
                 items: availableAggregations.map(aggregation => ({
                     name: aggregation.name,
                     value: [aggregation.short].concat(aggregation.fields.map(field => null)),
+                    isSelected: (agg) => !AggregationClause.isCustom(agg) && AggregationClause.getAggregation(agg) === aggregation.short,
                     aggregation: aggregation
                 })),
                 icon: "table2"
@@ -136,30 +149,71 @@ export default class AggregationPopover extends Component {
                 items: metrics.map(metric => ({
                     name: metric.name,
                     value: ["METRIC", metric.id],
+                    isSelected: (aggregation) => AggregationClause.getMetric(aggregation) === metric.id,
                     metric: metric
                 })),
                 icon: "star-outline"
             });
         }
 
+        let customExpressionIndex = sections.length;
+        sections.push({
+            name: CUSTOM_SECTION_NAME,
+            icon: "star-outline"
+        });
+
         if (sections.length === 1) {
             sections[0].name = null
         }
 
-        if (!choosingField) {
+        if (editingAggregation) {
             return (
-                <AccordianList
-                    className="text-green"
-                    sections={sections}
-                    onChange={this.onPickAggregation}
-                    itemIsSelected={this.itemIsSelected.bind(this)}
-                    renderSectionIcon={(s) => <Icon name={s.icon} size={18} />}
-                    renderItemExtra={this.renderItemExtra.bind(this)}
-                    getItemClasses={(item) => item.metric && !item.metric.is_active ? "text-grey-3" : null }
-                />
+                <div style={{width: editingAggregation ? 500 : 300}}>
+                    <div className="text-grey-3 p1 py2 border-bottom flex align-center">
+                        <a className="cursor-pointer flex align-center" onClick={this.onClearAggregation}>
+                            <Icon name="chevronleft" size={18}/>
+                            <h3 className="inline-block pl1">{CUSTOM_SECTION_NAME}</h3>
+                        </a>
+                    </div>
+                    <div className="p1">
+                        <ExpressionEditorTextfield
+                            startRule="aggregation"
+                            expression={aggregation}
+                            tableMetadata={tableMetadata}
+                            customFields={this.props.customFields}
+                            onChange={(parsedExpression) => this.setState({
+                                aggregation: NamedClause.setContent(this.state.aggregation, parsedExpression),
+                                error: null
+                            })}
+                            onError={(errorMessage) => this.setState({
+                                error: errorMessage
+                            })}
+                        />
+                        { this.state.error != null && (
+                            Array.isArray(this.state.error) ?
+                                this.state.error.map(error =>
+                                    <div className="text-error mb1" style={{ whiteSpace: "pre-wrap" }}>{error.message}</div>
+                                )
+                            :
+                                <div className="text-error mb1">{this.state.error.message}</div>
+                        )}
+                        <input
+                            className="input block full my1"
+                            value={NamedClause.getName(this.state.aggregation)}
+                            onChange={(e) => this.setState({
+                                aggregation: e.target.value ?
+                                    NamedClause.setName(aggregation, e.target.value) :
+                                    aggregation
+                            })}
+                            placeholder="Name (optional)"
+                        />
+                        <Button className="full" primary disabled={this.state.error} onClick={() => this.commitAggregation(this.state.aggregation)}>
+                            Done
+                        </Button>
+                    </div>
+                </div>
             );
-
-        } else {
+        } else if (choosingField) {
             const [agg, fieldId] = aggregation;
             return (
                 <div style={{width: 300}}>
@@ -180,6 +234,26 @@ export default class AggregationPopover extends Component {
                     />
                 </div>
             );
+        } else {
+            return (
+                <AccordianList
+                    className="text-green"
+                    sections={sections}
+                    onChange={this.onPickAggregation}
+                    itemIsSelected={this.itemIsSelected.bind(this)}
+                    renderSectionIcon={(s) => <Icon name={s.icon} size={18} />}
+                    renderItemExtra={this.renderItemExtra.bind(this)}
+                    getItemClasses={(item) => item.metric && !item.metric.is_active ? "text-grey-3" : null }
+                    onChangeSection={(index) => {
+                        if (index === customExpressionIndex) {
+                            this.onPickAggregation({
+                                custom: true,
+                                value: aggregation !== "rows" && !_.isEqual(aggregation, ["rows"]) ? aggregation : null
+                            })
+                        }
+                    }}
+                />
+            );
         }
     }
 }
diff --git a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx
index f8f44f9e5e6a6a5fc6845e5b8b4d9e463e77ae2e..96c9e0425e19cf1ed0df873c2c09cf4d60ec8c55 100644
--- a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx
+++ b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx
@@ -2,17 +2,18 @@ import React, { Component, PropTypes } from "react";
 
 import AggregationPopover from "./AggregationPopover.jsx";
 import FieldName from './FieldName.jsx';
+import Clearable from './Clearable.jsx';
 
 import Popover from "metabase/components/Popover.jsx";
 
 import Query from "metabase/lib/query";
-import { AggregationClause } from "metabase/lib/query";
+import { AggregationClause, NamedClause } from "metabase/lib/query";
 import { getAggregator } from "metabase/lib/schema_metadata";
+import { format } from "metabase/lib/expressions/formatter";
 
 import cx from "classnames";
 import _ from "underscore";
 
-
 export default class AggregationWidget extends Component {
     constructor(props, context) {
         super(props, context);
@@ -25,10 +26,11 @@ export default class AggregationWidget extends Component {
     }
 
     static propTypes = {
-        aggregation: PropTypes.array.isRequired,
+        aggregation: PropTypes.array,
         tableMetadata: PropTypes.object.isRequired,
         customFields: PropTypes.object,
-        updateAggregation: PropTypes.func.isRequired
+        updateAggregation: PropTypes.func.isRequired,
+        removeAggregation: PropTypes.func,
     };
 
     setAggregation(aggregation) {
@@ -48,27 +50,26 @@ export default class AggregationWidget extends Component {
         const fieldId = AggregationClause.getField(aggregation);
 
         let selectedAggregation = getAggregator(AggregationClause.getOperator(aggregation));
-        if (!_.findWhere(tableMetadata.aggregation_options, { short: selectedAggregation.short })) {
-            // if this table doesn't support the selected aggregation, prompt the user to select a different one
-            selectedAggregation = null;
-        }
-        return (
-            <div id="Query-section-aggregation" onClick={this.open} className="Query-section Query-section-aggregation cursor-pointer">
-                <span className="View-section-aggregation QueryOption py1 pl1">{selectedAggregation ? selectedAggregation.name.replace(" of ...", "") : "Choose an aggregation"}</span>
-                {aggregation.length > 1 &&
-                    <div className="View-section-aggregation flex align-center">
+        // if this table doesn't support the selected aggregation, prompt the user to select a different one
+        if (selectedAggregation && _.findWhere(tableMetadata.aggregation_options, { short: selectedAggregation.short })) {
+            return (
+                <span className="flex align-center">
+                    { selectedAggregation.name.replace(" of ...", "") }
+                    { fieldId &&
                         <span style={{paddingRight: "4px", paddingLeft: "4px"}} className="text-bold">of</span>
+                    }
+                    { fieldId &&
                         <FieldName
-                            className="View-section-aggregation-target SelectionModule py1 pr1"
+                            className="View-section-aggregation-target SelectionModule py1"
                             tableMetadata={tableMetadata}
                             field={fieldId}
                             fieldOptions={Query.getFieldOptions(tableMetadata.fields, true)}
                             customFieldOptions={this.props.customFields}
                         />
-                    </div>
-                }
-            </div>
-        );
+                    }
+                </span>
+            );
+        }
     }
 
     renderMetricAggregation() {
@@ -76,11 +77,14 @@ export default class AggregationWidget extends Component {
         const metricId = AggregationClause.getMetric(aggregation);
 
         let selectedMetric = _.findWhere(tableMetadata.metrics, { id: metricId });
-        return (
-            <div id="Query-section-aggregation" onClick={this.open} className="Query-section Query-section-aggregation cursor-pointer">
-                <span className="View-section-aggregation QueryOption p1">{selectedMetric ? selectedMetric.name.replace(" of ...", "") : "Choose an aggregation"}</span>
-            </div>
-        );
+        if (selectedMetric) {
+            return selectedMetric.name.replace(" of ...", "")
+        }
+    }
+
+    renderCustomAggregation() {
+        const { aggregation, tableMetadata, customFields } = this.props;
+        return format(aggregation, { tableMetadata, customFields });
     }
 
     renderPopover() {
@@ -94,6 +98,7 @@ export default class AggregationWidget extends Component {
                     className="FilterPopover"
                     isInitiallyOpen={true}
                     onClose={this.close}
+                    dismissOnEscape={false} // disable for expression editor
                 >
                     <AggregationPopover
                         aggregation={aggregation}
@@ -109,24 +114,46 @@ export default class AggregationWidget extends Component {
     }
 
     render() {
-        const { aggregation } = this.props;
+        const { aggregation, addButton, name } = this.props;
+        if (aggregation && aggregation.length > 0) {
+            let aggregationName = NamedClause.isNamed(aggregation) ?
+                NamedClause.getName(aggregation)
+            : AggregationClause.isCustom(aggregation) ?
+                this.renderCustomAggregation()
+            : AggregationClause.isMetric(aggregation) ?
+                this.renderMetricAggregation()
+            :
+                this.renderStandardAggregation()
 
-        if (!aggregation || aggregation.length === 0) {
-            // we can't do anything without a valid aggregation
-            return <span/>;
-        }
-
-        return (
-            <div className={cx("Query-section Query-section-aggregation", { "selected": this.state.isOpen })}>
-                <div>
-                    {AggregationClause.isMetric(aggregation) ?
-                        this.renderMetricAggregation()
-                    :
-                        this.renderStandardAggregation()
-                    }
+            return (
+                <div className={cx("Query-section Query-section-aggregation", { "selected": this.state.isOpen })}>
+                    <div>
+                        <Clearable onClear={this.props.removeAggregation}>
+                            <div id="Query-section-aggregation" onClick={this.open} className="Query-section Query-section-aggregation cursor-pointer">
+                                <span className="View-section-aggregation QueryOption py1 mx1">
+                                    { aggregationName == null ?
+                                        "Choose an aggregation"
+                                    : name ?
+                                        name
+                                    :
+                                        aggregationName
+                                    }
+                                </span>
+                            </div>
+                        </Clearable>
+                        {this.renderPopover()}
+                    </div>
+                </div>
+            );
+        } else if (addButton) {
+            return (
+                <div className={cx("Query-section Query-section-aggregation")} onClick={this.open}>
+                    {addButton}
                     {this.renderPopover()}
                 </div>
-            </div>
-        );
+            );
+        } else {
+            return null;
+        }
     }
 }
diff --git a/frontend/src/metabase/query_builder/components/Clearable.jsx b/frontend/src/metabase/query_builder/components/Clearable.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d5e08a7dd7d2b21d10eef2ad49b0fa2ae7333346
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/Clearable.jsx
@@ -0,0 +1,15 @@
+import React from "react";
+
+import Icon from "metabase/components/Icon.jsx";
+
+const Clearable = ({ onClear, children }) =>
+    <div className="flex align-center">
+        {children}
+        { onClear &&
+            <a className="text-grey-2 no-decoration pr1 flex align-center" onClick={onClear}>
+                <Icon name='close' size={14} />
+            </a>
+        }
+    </div>
+
+export default Clearable;
diff --git a/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx b/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx
index 2e0f836956c5f37f1ab3a8f08bc171fd2dc0bd57..43bf3544f570ecfeb43f0c9e990da516c73dfdf0 100644
--- a/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx
+++ b/frontend/src/metabase/query_builder/components/ExtendedOptions.jsx
@@ -25,7 +25,7 @@ export default class ExtendedOptions extends Component {
 
         _.bindAll(
             this,
-            "setLimit", "addSort", "updateSort", "removeSort"
+            "setLimit", "addOrderBy", "updateOrderBy", "removeOrderBy"
         );
     }
 
@@ -46,29 +46,29 @@ export default class ExtendedOptions extends Component {
             Query.updateLimit(this.props.query.query, limit);
             MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Limit');
         } else {
-            Query.removeLimit(this.props.query.query);
+            Query.clearLimit(this.props.query.query);
             MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Limit');
         }
         this.props.setQuery(this.props.query);
         this.setState({isOpen: false});
     }
 
-    addSort() {
-        Query.addSort(this.props.query.query);
+    addOrderBy() {
+        Query.addOrderBy(this.props.query.query, [null, "ascending"]);
         this.props.setQuery(this.props.query);
 
         MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'manual');
     }
 
-    updateSort(index, sort) {
-        Query.updateSort(this.props.query.query, index, sort);
+    updateOrderBy(index, sort) {
+        Query.updateOrderBy(this.props.query.query, index, sort);
         this.props.setQuery(this.props.query);
 
         MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'manual');
     }
 
-    removeSort(index) {
-        Query.removeSort(this.props.query.query, index);
+    removeOrderBy(index) {
+        Query.removeOrderBy(this.props.query.query, index);
         this.props.setQuery(this.props.query);
 
         MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Sort');
@@ -100,49 +100,67 @@ export default class ExtendedOptions extends Component {
     }
 
     renderSort() {
+        const { query: { query }, tableMetadata } = this.props;
+
         if (!this.props.features.limit) {
             return;
         }
 
-        var sortFieldOptions;
-
-        if (this.props.tableMetadata) {
-            sortFieldOptions = Query.getFieldOptions(
-                this.props.tableMetadata.fields,
-                true,
-                Query.getSortableFields.bind(null, this.props.query.query)
+        let sortList, addSortButton;
+
+        if (tableMetadata) {
+            const sorts = Query.getOrderBys(query);
+            const expressions = Query.getExpressions(query);
+
+            const usedFields = {};
+            const usedExpressions = {};
+            for (const sort of sorts) {
+                if (Query.isExpressionField(sort[0])) {
+                    usedExpressions[sort[0][1]] = true;
+                } else {
+                    usedFields[sort[0]] = true;
+                }
+            }
+
+            sortList = sorts.map((sort, index) =>
+                <SortWidget
+                    key={index}
+                    tableMetadata={tableMetadata}
+                    sort={sort}
+                    fieldOptions={
+                        Query.getFieldOptions(
+                            tableMetadata.fields,
+                            true,
+                            Query.getSortableFields.bind(null, query),
+                            _.omit(usedFields, sort[0])
+                        )
+                    }
+                    customFieldOptions={expressions}
+                    removeOrderBy={this.removeOrderBy.bind(null, index)}
+                    updateOrderBy={this.updateOrderBy.bind(null, index)}
+                />
             );
-        }
 
-        var sortList = [];
-        if (this.props.query.query.order_by && this.props.tableMetadata) {
-            sortList = this.props.query.query.order_by.map((order_by, index) => {
-                return (
-                    <SortWidget
-                        key={index}
-                        tableMetadata={this.props.tableMetadata}
-                        sort={order_by}
-                        fieldOptions={sortFieldOptions}
-                        customFieldOptions={Query.getExpressions(this.props.query.query)}
-                        removeSort={this.removeSort.bind(null, index)}
-                        updateSort={this.updateSort.bind(null, index)}
-                    />
-                );
-            });
-        }
 
-        var content;
-        if (sortList.length > 0) {
-            content = sortList;
-        } else if (sortFieldOptions && sortFieldOptions.count > 0) {
-            content = (<AddClauseButton text="Pick a field to sort by" onClick={this.addSort} />);
+            const remainingFieldOptions = Query.getFieldOptions(
+                tableMetadata.fields,
+                true,
+                Query.getSortableFields.bind(null, query),
+                usedFields
+            );
+            const remainingExpressions = Object.keys(_.omit(expressions, usedExpressions));
+            if ((remainingFieldOptions.count > 0 || remainingExpressions.length > 1) &&
+                (sorts.length === 0 || sorts[sorts.length - 1][0] != null)) {
+                addSortButton = (<AddClauseButton text="Pick a field to sort by" onClick={this.addOrderBy} />);
+            }
         }
 
-        if (content) {
+        if ((sortList && sortList.length > 0) || addSortButton) {
             return (
                 <div className="pb3">
                     <div className="pb1 h6 text-uppercase text-grey-3 text-bold">Sort</div>
-                    {content}
+                    {sortList}
+                    {addSortButton}
                 </div>
             );
         }
diff --git a/frontend/src/metabase/query_builder/components/FieldList.jsx b/frontend/src/metabase/query_builder/components/FieldList.jsx
index 15a0dea1f912b83a14de50a801dd32ded5956ed1..2ba51639220287bfd1e96cbd8956743674278ee1 100644
--- a/frontend/src/metabase/query_builder/components/FieldList.jsx
+++ b/frontend/src/metabase/query_builder/components/FieldList.jsx
@@ -63,7 +63,7 @@ export default class FieldList extends Component {
             name: singularize(tableName),
             items: specialOptions.concat(fieldOptions.fields.map(field => ({
                 name: Query.getFieldPathName(field.id, tableMetadata),
-                value: field.id,
+                value: ["field-id", field.id],
                 field: field
             })))
         };
@@ -88,7 +88,11 @@ export default class FieldList extends Component {
     }
 
     itemIsSelected(item) {
-        return _.isEqual(this.state.fieldTarget, item.value);
+        let { fieldTarget } = this.state;
+        if (typeof fieldTarget === "number") {
+            fieldTarget = ["field-id", fieldTarget];
+        }
+        return _.isEqual(fieldTarget, item.value);
     }
 
     renderItemExtra(item) {
diff --git a/frontend/src/metabase/query_builder/components/FieldName.jsx b/frontend/src/metabase/query_builder/components/FieldName.jsx
index cf6d52163cf9f383a3f8054d271f4ff5c5e1fb92..d21e0f44cf5f2d4b39df63adcf0f633e3ffef4aa 100644
--- a/frontend/src/metabase/query_builder/components/FieldName.jsx
+++ b/frontend/src/metabase/query_builder/components/FieldName.jsx
@@ -1,6 +1,7 @@
 import React, { Component, PropTypes } from "react";
 
 import Icon from "metabase/components/Icon.jsx";
+import Clearable from "./Clearable.jsx";
 
 import Query from "metabase/lib/query";
 import { formatBucketing } from "metabase/lib/query_time";
@@ -36,9 +37,12 @@ export default class FieldName extends Component {
                 parts.push(<span key={"fkName"+index}>{stripId(fkField.display_name)}</span>);
                 parts.push(<span key={"fkIcon"+index} className="px1"><Icon name="connections" size={10} /></span>);
             }
-            // target field itself
-            // using i.getIn to avoid exceptions when field is undefined
-            parts.push(<span key="field">{Query.getFieldPathName(fieldTarget.field.id, fieldTarget.table)}</span>);
+            if (fieldTarget.field.id != null) {
+                parts.push(<span key="field">{Query.getFieldPathName(fieldTarget.field.id, fieldTarget.table)}</span>);
+            } else {
+                // expressions, etc
+                parts.push(<span key="field">{fieldTarget.field.display_name}</span>);
+            }
             // datetime-field unit
             if (fieldTarget.unit != null) {
                 parts.push(<span key="unit">{": " + formatBucketing(fieldTarget.unit)}</span>);
@@ -48,16 +52,11 @@ export default class FieldName extends Component {
         }
 
         return (
-            <div className="flex align-center">
+            <Clearable onClear={this.props.removeField}>
                 <div className={cx(className, { selected: Query.isValidField(field) })} onClick={this.props.onClick}>
                     <span className="QueryOption">{parts}</span>
                 </div>
-                { this.props.removeField &&
-                    <a className="text-grey-2 no-decoration pr1 flex align-center" onClick={this.props.removeField}>
-                        <Icon name='close' size={14} />
-                    </a>
-                }
-            </div>
+            </Clearable>
         );
     }
 }
diff --git a/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx b/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx
index 1ad0c4ca0f84dc6d913d5251f1dcc4bc27fa7e28..07909db70404626e288ec0de2f82b6b0954239e6 100644
--- a/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx
+++ b/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx
@@ -28,8 +28,6 @@ export default class GuiQueryEditor extends Component {
 
         _.bindAll(
             this,
-            "addFilter", "updateFilter", "removeFilter",
-            "updateAggregation",
             "setBreakout",
         );
     }
@@ -60,49 +58,54 @@ export default class GuiQueryEditor extends Component {
         this.props.setQueryFn(datasetQuery);
     }
 
-    setBreakout(index, field) {
+    setBreakout = (index, field) => {
         if (field == null) {
-            Query.removeDimension(this.props.query.query, index);
+            Query.removeBreakout(this.props.query.query, index);
             this.setQuery(this.props.query);
             MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove GroupBy');
         } else {
-            let isNew = index+1 > this.props.query.query.breakout.length;
-            Query.updateDimension(this.props.query.query, field, index);
-            this.setQuery(this.props.query);
-
-            if (isNew) {
+            if (index > Query.getBreakouts(this.props.query.query) - 1) {
+                Query.addBreakout(this.props.query.query, field);
+                this.setQuery(this.props.query);
                 MetabaseAnalytics.trackEvent('QueryBuilder', 'Add GroupBy');
             } else {
+                Query.updateBreakout(this.props.query.query, index, field);
+                this.setQuery(this.props.query);
                 MetabaseAnalytics.trackEvent('QueryBuilder', 'Modify GroupBy');
             }
         }
     }
 
-    updateAggregation(aggregationClause) {
-        Query.updateAggregation(this.props.query.query, aggregationClause);
+    updateAggregation = (index, aggregationClause) => {
+        Query.updateAggregation(this.props.query.query, index, aggregationClause);
         this.setQuery(this.props.query);
 
         MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Aggregation', aggregationClause[0]);
     }
 
-    addFilter(filter) {
-        let query = this.props.query.query;
-        Query.addFilter(query);
-        Query.updateFilter(query, Query.getFilters(query).length - 1, filter);
-
+    removeAggregation = (index, aggregationClause) => {
+        Query.removeAggregation(this.props.query.query, index);
         this.setQuery(this.props.query);
 
+        MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Aggregation', aggregationClause[0]);
+    }
+
+    addFilter = (filter) => {
+        const query = this.props.query;
+        Query.addFilter(query.query, filter);
+        this.setQuery(query);
+
         MetabaseAnalytics.trackEvent('QueryBuilder', 'Add Filter');
     }
 
-    updateFilter(index, filter) {
+    updateFilter = (index, filter) => {
         Query.updateFilter(this.props.query.query, index, filter);
         this.setQuery(this.props.query);
 
         MetabaseAnalytics.trackEvent('QueryBuilder', 'Modify Filter');
     }
 
-    removeFilter(index) {
+    removeFilter = (index) => {
         Query.removeFilter(this.props.query.query, index);
         this.setQuery(this.props.query);
 
@@ -114,15 +117,15 @@ export default class GuiQueryEditor extends Component {
         if (onClick) {
             return (
                 <a className={className} onClick={onClick}>
+                    { text && <span className="mr1">{text}</span> }
                     {this.renderAddIcon(targetRefName)}
-                    { text && <span className="ml1">{text}</span> }
                 </a>
             );
         } else {
             return (
                 <span className={className}>
+                    { text && <span className="mr1">{text}</span> }
                     {this.renderAddIcon(targetRefName)}
-                    { text && <span className="ml1">{text}</span> }
                 </span>
             );
         }
@@ -158,7 +161,6 @@ export default class GuiQueryEditor extends Component {
                 );
             }
 
-            // TODO: proper check for isFilterComplete(filter)
             if (Query.canAddFilter(this.props.query.query)) {
                 addFilterButton = this.renderAdd((filterList ? null : "Add filters to narrow your answer"), null, "addFilterTarget");
             }
@@ -195,20 +197,48 @@ export default class GuiQueryEditor extends Component {
     }
 
     renderAggregation() {
+        const { query: { query }, tableMetadata } = this.props;
+
         if (!this.props.features.aggregation) {
             return;
         }
 
         // aggregation clause.  must have table details available
-        if (this.props.tableMetadata) {
-            return (
-                <AggregationWidget
-                    aggregation={this.props.query.query.aggregation}
-                    tableMetadata={this.props.tableMetadata}
-                    customFields={Query.getExpressions(this.props.query.query)}
-                    updateAggregation={this.updateAggregation}
-                />
-            );
+        if (tableMetadata) {
+            let isBareRows = Query.isBareRows(query);
+            let aggregations = Query.getAggregations(query);
+
+            if (aggregations.length === 0) {
+                // add implicit rows aggregation
+                aggregations.push(["rows"]);
+            }
+
+            const canRemoveAggregation = aggregations.length > 1;
+
+            if (!isBareRows) {
+                aggregations.push([]);
+            }
+
+            let aggregationList = [];
+            for (const [index, aggregation] of aggregations.entries()) {
+                aggregationList.push(
+                    <AggregationWidget
+                        key={"agg"+index}
+                        aggregation={aggregation}
+                        tableMetadata={tableMetadata}
+                        customFields={Query.getExpressions(this.props.query.query)}
+                        updateAggregation={(aggregation) => this.updateAggregation(index, aggregation)}
+                        removeAggregation={canRemoveAggregation ? this.removeAggregation.bind(null, index) : null}
+                        addButton={this.renderAdd(null)}
+                    />
+                );
+                if (aggregations[index + 1] != null && aggregations[index + 1].length > 0) {
+                    aggregationList.push(
+                        <span key={"and"+index} className="text-bold">and</span>
+                    );
+                }
+            }
+            return aggregationList
         } else {
             // TODO: move this into AggregationWidget?
             return (
@@ -220,71 +250,58 @@ export default class GuiQueryEditor extends Component {
     }
 
     renderBreakouts() {
-        if (!this.props.features.breakout) {
+        const { query: { query }, tableMetadata, features } = this.props;
+
+        if (!features.breakout) {
             return;
         }
 
-        var enabled = (this.props.tableMetadata &&
-                       this.props.tableMetadata.breakout_options.fields.length > 0 &&
-                       !Query.hasEmptyAggregation(this.props.query.query));
-        var breakoutList = [];
+        const enabled = tableMetadata && tableMetadata.breakout_options.fields.length > 0;
+        const breakoutList = [];
 
-        const breakout = this.props.query.query.breakout;
         if (enabled) {
-            if (breakout.length === 0) {
-                // no breakouts specified yet, so just render a single widget
-                breakoutList.push(
-                    <BreakoutWidget
-                        className="View-section-breakout SelectionModule p1"
-                        fieldOptions={Query.getFieldOptions(this.props.tableMetadata.fields, true, this.props.tableMetadata.breakout_options.validFieldsFilter, {})}
-                        customFieldOptions={Query.getExpressions(this.props.query.query)}
-                        tableMetadata={this.props.tableMetadata}
-                        setField={(field) => this.setBreakout(0, field)}
-                        addButton={this.renderAdd("Add a grouping")}
-                    />
-                );
+            const breakouts = Query.getBreakouts(query);
 
-            } else {
-                // we have 1+ defined breakouts, so provide 2 widgets
-                breakoutList.push(
-                    <span className="text-bold">by</span>
-                );
+            const usedFields = {};
+            for (const breakout of breakouts) {
+                usedFields[breakout] = true;
+            }
+
+            const remainingFieldOptions = Query.getFieldOptions(tableMetadata.fields, true, tableMetadata.breakout_options.validFieldsFilter, usedFields);
+            if (remainingFieldOptions.count > 0 && (breakouts.length === 0 || breakouts[breakouts.length - 1] != null)) {
+                breakouts.push(null);
+            }
+
+            for (let i = 0; i < breakouts.length; i++) {
+                const breakout = breakouts[i];
+
+                if (breakout == null) {
+                    breakoutList.push(<span className="ml1" />);
+                }
 
                 breakoutList.push(
                     <BreakoutWidget
-                        key={"breakout0"}
+                        key={"breakout"+i}
                         className="View-section-breakout SelectionModule p1"
-                        fieldOptions={Query.getFieldOptions(this.props.tableMetadata.fields, true, this.props.tableMetadata.breakout_options.validFieldsFilter, {})}
-                        customFieldOptions={Query.getExpressions(this.props.query.query)}
-                        tableMetadata={this.props.tableMetadata}
-                        field={breakout[0]}
-                        setField={(fieldId) => this.setBreakout(0, fieldId)}
+                        fieldOptions={Query.getFieldOptions(tableMetadata.fields, true, tableMetadata.breakout_options.validFieldsFilter, _.omit(usedFields, breakout))}
+                        customFieldOptions={Query.getExpressions(query)}
+                        tableMetadata={tableMetadata}
+                        field={breakout}
+                        setField={(field) => this.setBreakout(i, field)}
+                        addButton={this.renderAdd(i === 0 ? "Add a grouping" : null)}
                     />
                 );
 
-                if (breakout.length === 2) {
+                if (breakouts[i + 1] != null) {
                     breakoutList.push(
-                        <span className="text-bold">and</span>
+                        <span key={"and"+i} className="text-bold">and</span>
                     );
                 }
-
-                breakoutList.push(
-                    <BreakoutWidget
-                        key={"breakout1"}
-                        className="View-section-breakout SelectionModule p1"
-                        fieldOptions={Query.getFieldOptions(this.props.tableMetadata.fields, true, this.props.tableMetadata.breakout_options.validFieldsFilter, {[breakout[0]]: true})}
-                        customFieldOptions={Query.getExpressions(this.props.query.query)}
-                        tableMetadata={this.props.tableMetadata}
-                        field={breakout.length > 1 ? breakout[1] : null}
-                        setField={(field) => this.setBreakout(1, field)}
-                        addButton={this.renderAdd()}
-                    />
-                );
             }
         }
 
         return (
-            <div className={cx("Query-section Query-section-breakout ml1", { disabled: !enabled })}>
+            <div className={cx("Query-section Query-section-breakout", { disabled: !enabled })}>
                 {breakoutList}
             </div>
         );
@@ -334,9 +351,22 @@ export default class GuiQueryEditor extends Component {
         }
 
         return (
-            <div className="GuiBuilder-section GuiBuilder-view flex align-center px1" ref="viewSection">
+            <div className="GuiBuilder-section GuiBuilder-view flex align-center px1 pr2" ref="viewSection">
                 <span className="GuiBuilder-section-label Query-label">View</span>
                 {this.renderAggregation()}
+            </div>
+        );
+    }
+
+    renderGroupedBySection() {
+        const { features } = this.props;
+        if (!features.aggregation && !features.breakout) {
+            return;
+        }
+
+        return (
+            <div className="GuiBuilder-section GuiBuilder-groupedBy flex align-center px1" ref="viewSection">
+                <span className="GuiBuilder-section-label Query-label">Grouped By</span>
                 {this.renderBreakouts()}
             </div>
         );
@@ -344,7 +374,7 @@ export default class GuiQueryEditor extends Component {
 
     componentDidUpdate() {
         // HACK: magic number "5" accounts for the borders between the sections?
-        let contentWidth = ["data", "filter", "view", "sortLimit"].reduce((acc, ref) => {
+        let contentWidth = ["data", "filter", "view", "groupedBy","sortLimit"].reduce((acc, ref) => {
             let node = ReactDOM.findDOMNode(this.refs[`${ref}Section`]);
             return acc + (node ? node.offsetWidth : 0);
         }, 0) + 5;
@@ -365,6 +395,7 @@ export default class GuiQueryEditor extends Component {
                 </div>
                 <div className="GuiBuilder-row flex flex-full">
                     {this.renderViewSection()}
+                    {this.renderGroupedBySection()}
                     <div className="flex-full"></div>
                     {this.props.children}
                     <ExtendedOptions
diff --git a/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx b/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx
index c34fbc9fcc6828d53b580e3d02c7aa8a5152b052..d86211846e3dc53b74ec7653ade9deee383bb5fd 100644
--- a/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx
+++ b/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx
@@ -31,12 +31,12 @@ export default class QueryDefinitionTooltip extends Component {
                     <div className="mt2">
                         <FieldSet legend="Definition" className="border-light">
                             <div className="TooltipFilterList">
-                                { object.definition.aggregation &&
+                                { Query.getAggregations(object.definition).map(aggregation =>
                                     <AggregationWidget
-                                        aggregation={object.definition.aggregation}
+                                        aggregation={aggregation}
                                         tableMetadata={tableMetadata}
                                     />
-                                }
+                                )}
                                 <FilterList
                                     filters={Query.getFilters(object.definition)}
                                     tableMetadata={tableMetadata}
diff --git a/frontend/src/metabase/query_builder/components/QueryHeader.jsx b/frontend/src/metabase/query_builder/components/QueryHeader.jsx
index 9a5005615a328dcd4ec08425cc56a20616b13062..f394ebd5cac657b8ac892ff5de98034f211ff78b 100644
--- a/frontend/src/metabase/query_builder/components/QueryHeader.jsx
+++ b/frontend/src/metabase/query_builder/components/QueryHeader.jsx
@@ -1,4 +1,5 @@
 import React, { Component, PropTypes } from "react";
+import { Link } from "react-router";
 
 import QueryModeButton from "./QueryModeButton.jsx";
 
@@ -12,14 +13,17 @@ import Icon from "metabase/components/Icon.jsx";
 import Modal from "metabase/components/Modal.jsx";
 import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx";
 import QuestionSavedModal from 'metabase/components/QuestionSavedModal.jsx';
-import SaveQuestionModal from 'metabase/components/SaveQuestionModal.jsx';
 import Tooltip from "metabase/components/Tooltip.jsx";
+import MoveToCollection from "metabase/questions/containers/MoveToCollection.jsx";
+
+import SaveQuestionModal from 'metabase/containers/SaveQuestionModal.jsx';
 
 import { CardApi, RevisionApi } from "metabase/services";
 
 import MetabaseAnalytics from "metabase/lib/analytics";
 import Query from "metabase/lib/query";
 import { cancelable } from "metabase/lib/promise";
+import Urls from "metabase/lib/urls";
 
 import cx from "classnames";
 import _ from "underscore";
@@ -182,6 +186,8 @@ export default class QueryHeader extends Component {
         if (isNew && isDirty) {
             buttonSections.push([
                 <ModalWithTrigger
+                    full
+                    form
                     key="save"
                     ref="saveModal"
                     triggerClasses="h4 text-grey-4 text-brand-hover text-uppercase"
@@ -194,7 +200,7 @@ export default class QueryHeader extends Component {
                         addToDashboard={false}
                         saveFn={this.onSave}
                         createFn={this.onCreate}
-                        closeFn={() => this.refs.saveModal.toggle()}
+                        onClose={() => this.refs.saveModal.toggle()}
                     />
                 </ModalWithTrigger>
             ]);
@@ -256,11 +262,28 @@ export default class QueryHeader extends Component {
                             <DeleteQuestionModal
                                 card={this.props.card}
                                 deleteCardFn={this.onDelete}
-                                closeFn={() => this.refs.deleteModal.toggle()}
+                                onClose={() => this.refs.deleteModal.toggle()}
                             />
                         </ModalWithTrigger>
                     </Tooltip>
                 ]);
+
+                buttonSections.push([
+                    <ModalWithTrigger
+                        ref="move"
+                        full
+                        triggerElement={
+                            <Tooltip tooltip="Move question">
+                                <Icon name="move" />
+                            </Tooltip>
+                        }
+                    >
+                        <MoveToCollection
+                            questionId={this.props.card.id}
+                            initialCollectionId={this.props.card && this.props.card.collection_id}
+                        />
+                    </ModalWithTrigger>
+                ]);
             }
         }
 
@@ -305,7 +328,7 @@ export default class QueryHeader extends Component {
                             addToDashboard={true}
                             saveFn={this.onSave}
                             createFn={this.onCreate}
-                            closeFn={() => this.refs.addToDashSaveModal.toggle()}
+                            onClose={() => this.refs.addToDashSaveModal.toggle()}
                         />
                     </ModalWithTrigger>
                 </Tooltip>
@@ -376,7 +399,7 @@ export default class QueryHeader extends Component {
 
     render() {
         return (
-            <div>
+            <div className="relative">
                 <HeaderBar
                     isEditing={this.props.isEditing}
                     name={this.props.isNew ? "New question" : this.props.card.name}
@@ -384,19 +407,29 @@ export default class QueryHeader extends Component {
                     breadcrumb={(!this.props.card.id && this.props.originalCard) ? (<span className="pl2">started from <a className="link" onClick={this.onFollowBreadcrumb}>{this.props.originalCard.name}</a></span>) : null }
                     buttons={this.getHeaderButtons()}
                     setItemAttributeFn={this.props.onSetCardAttribute}
+                    badge={this.props.card.collection &&
+                        <Link
+                            to={Urls.collection(this.props.card.collection)}
+                            className="text-uppercase flex align-center no-decoration"
+                            style={{ color: this.props.card.collection.color, fontSize: 12 }}
+                        >
+                            <Icon name="collection" size={12} style={{ marginRight: "0.5em" }} />
+                            {this.props.card.collection.name}
+                        </Link>
+                    }
                 />
 
-                <Modal className="Modal Modal--small" isOpen={this.state.modal === "saved"} onClose={this.onCloseModal}>
+                <Modal small isOpen={this.state.modal === "saved"} onClose={this.onCloseModal}>
                     <QuestionSavedModal
                         addToDashboardFn={() => this.setState({ modal: "add-to-dashboard" })}
-                        closeFn={this.onCloseModal}
+                        onClose={this.onCloseModal}
                     />
                 </Modal>
 
                 <Modal isOpen={this.state.modal === "add-to-dashboard"} onClose={this.onCloseModal}>
                     <AddToDashSelectDashModal
                         card={this.props.card}
-                        closeFn={this.onCloseModal}
+                        onClose={this.onCloseModal}
                         onChangeLocation={this.props.onChangeLocation}
                     />
                 </Modal>
diff --git a/frontend/src/metabase/query_builder/components/QueryModeButton.jsx b/frontend/src/metabase/query_builder/components/QueryModeButton.jsx
index 7e9f9ab0045945e7b8e5681fd0c4e7a066093368..6e0fd438dc233057f7d54f366a3254da773b9b47 100644
--- a/frontend/src/metabase/query_builder/components/QueryModeButton.jsx
+++ b/frontend/src/metabase/query_builder/components/QueryModeButton.jsx
@@ -58,7 +58,7 @@ export default class QueryModeButton extends Component {
                     </span>
                 </Tooltip>
 
-                <Modal className="Modal Modal--medium" backdropClassName="Modal-backdrop-dark" isOpen={this.state.isOpen} onClose={() => this.setState({isOpen: false})}>
+                <Modal medium backdropClassName="Modal-backdrop--dark" isOpen={this.state.isOpen} onClose={() => this.setState({isOpen: false})}>
                     <div className="p4">
                         <div className="mb3 flex flex-row flex-full align-center justify-between">
                             <h2>{capitalize(nativeQueryName)} for this question</h2>
diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionIntroModal.jsx b/frontend/src/metabase/query_builder/components/SavedQuestionIntroModal.jsx
index 48c672333b76b3ddb13240c93ab851d4bb9de6ce..c4a08ab92732f8b9166fac9bfbc44c0bd36bf13b 100644
--- a/frontend/src/metabase/query_builder/components/SavedQuestionIntroModal.jsx
+++ b/frontend/src/metabase/query_builder/components/SavedQuestionIntroModal.jsx
@@ -7,7 +7,7 @@ export default class SavedQuestionIntroModal extends Component {
 
     render() {
         return (
-            <Modal className="Modal Modal--small" isOpen={this.props.isShowingNewbModal}>
+            <Modal small isOpen={this.props.isShowingNewbModal}>
                 <div className="Modal-content Modal-content--small NewForm">
                     <div className="Modal-header Form-header">
                         <h2 className="pb2 text-dark">It's okay to play around with saved questions</h2>
diff --git a/frontend/src/metabase/query_builder/components/SortWidget.jsx b/frontend/src/metabase/query_builder/components/SortWidget.jsx
index 03a31211b0068ab0d8b6ce6c170c9545436698be..f499138daa38ccfb725ebcd1412282e5de01081b 100644
--- a/frontend/src/metabase/query_builder/components/SortWidget.jsx
+++ b/frontend/src/metabase/query_builder/components/SortWidget.jsx
@@ -18,8 +18,8 @@ export default class SortWidget extends Component {
         fieldOptions: PropTypes.object.isRequired,
         customFieldOptions: PropTypes.object,
         tableName: PropTypes.string,
-        updateSort: PropTypes.func.isRequired,
-        removeSort: PropTypes.func.isRequired,
+        updateOrderBy: PropTypes.func.isRequired,
+        removeOrderBy: PropTypes.func.isRequired,
         tableMetadata: PropTypes.object.isRequired
     };
 
@@ -37,13 +37,13 @@ export default class SortWidget extends Component {
     componentWillUnmount() {
         // Remove partially completed sort if the widget is removed
         if (this.state.field == null || this.state.direction == null) {
-            this.props.removeSort();
+            this.props.removeOrderBy();
         }
     }
 
     setField(value) {
         if (this.state.field !== value) {
-            this.props.updateSort([value, this.state.direction]);
+            this.props.updateOrderBy([value, this.state.direction]);
             // Optimistically set field state so componentWillUnmount logic works correctly
             this.setState({ field: value });
         }
@@ -51,7 +51,7 @@ export default class SortWidget extends Component {
 
     setDirection(value) {
         if (this.state.direction !== value) {
-            this.props.updateSort([this.state.field, value]);
+            this.props.updateOrderBy([this.state.field, value]);
             // Optimistically set direction state so componentWillUnmount logic works correctly
             this.setState({ direction: value });
         }
@@ -87,7 +87,7 @@ export default class SortWidget extends Component {
                     action={this.setDirection}
                 />
 
-                <a onClick={this.props.removeSort}>
+                <a onClick={this.props.removeOrderBy}>
                     <Icon name='close' size={12} />
                 </a>
             </div>
diff --git a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx
index 16d43e192322e17773e44a57eeb1ef8238d522e8..8ea7b0a136679edff6cb965d3cae992b63aa454f 100644
--- a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx
+++ b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx
@@ -35,7 +35,11 @@ export default class VisualizationSettings extends React.Component {
 
         var triggerElement = (
             <span className="px2 py1 text-bold cursor-pointer text-default flex align-center">
-                <Icon name={CardVisualization.iconName} size={12} />
+                <Icon
+                    className="mr1"
+                    name={CardVisualization.iconName}
+                    size={12}
+                />
                 {CardVisualization.displayName}
                 <Icon className="ml1" name="chevrondown" size={8} />
             </span>
@@ -43,7 +47,12 @@ export default class VisualizationSettings extends React.Component {
 
         return (
             <div className="relative">
-                <span className="GuiBuilder-section-label Query-label">Visualization</span>
+                <span
+                    className="GuiBuilder-section-label pl0 Query-label"
+                    style={{ marginLeft: 4 }}
+                >
+                    Visualization
+                </span>
                 <PopoverWithTrigger
                     id="VisualizationPopover"
                     ref="displayPopover"
@@ -83,8 +92,12 @@ export default class VisualizationSettings extends React.Component {
                 <div className="VisualizationSettings flex align-center">
                     {this.renderChartTypePicker()}
                     <ModalWithTrigger
-                        className="Modal Modal--wide Modal--tall"
-                        triggerElement={<span data-metabase-event="Query Builder;Chart Settings"><Icon name="gear"/></span>}
+                        wide tall
+                        triggerElement={
+                            <span data-metabase-event="Query Builder;Chart Settings">
+                                <Icon name="gear"/>
+                            </span>
+                        }
                         triggerClasses="text-brand-hover"
                         ref="popover"
                     >
diff --git a/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx b/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx
index 51f10ad28146eb2b1eebd0f4b0bfceae8fe223e3..0ec813278707e512880b186cc7fcf6ac9623ce83 100644
--- a/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx
+++ b/frontend/src/metabase/query_builder/components/dataref/DataReference.jsx
@@ -25,7 +25,7 @@ export default class DataReference extends Component {
 
     static propTypes = {
         query: PropTypes.object.isRequired,
-        closeFn: PropTypes.func.isRequired,
+        onClose: PropTypes.func.isRequired,
         runQueryFn: PropTypes.func.isRequired,
         setQueryFn: PropTypes.func.isRequired,
         setDatabaseFn: PropTypes.func.isRequired,
@@ -34,7 +34,7 @@ export default class DataReference extends Component {
     };
 
     close() {
-        this.props.closeFn();
+        this.props.onClose();
     }
 
     back() {
diff --git a/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx b/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx
index 04d01c778f5d3d4d29585c7e1a01d1ce4c932856..64b6f8cf7fe720932d6695dc46b7df8970fa8243 100644
--- a/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx
+++ b/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx
@@ -51,20 +51,18 @@ export default class FieldPane extends Component {
         var query = this.setDatabaseAndTable();
         // Add an aggregation so both aggregation and filter popovers aren't visible
         if (!Query.hasValidAggregation(query.query)) {
-            Query.updateAggregation(query.query, ["rows"]);
+            Query.clearAggregations(query.query);
         }
-        Query.addFilter(query.query);
-        Query.updateFilter(query.query, Query.getFilters(query.query).length - 1, [null, this.props.field.id, null]);
+        Query.addFilter(query.query, [null, this.props.field.id, null]);
         this.props.setQueryFn(query);
     }
 
     groupBy() {
         let query = this.props.query;
         if (!Query.hasValidAggregation(query.query)) {
-            Query.updateAggregation(query.query, ["rows"]);
+            Query.clearAggregations(query.query);
         }
-        Query.addDimension(query.query);
-        Query.updateDimension(query.query, this.props.field.id, query.query.breakout.length - 1);
+        Query.addBreakout(query.query, this.props.field.id);
         this.props.setQueryFn(query);
         this.props.runQueryFn();
     }
diff --git a/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx b/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx
index ab561ba35c157110b193810059224bd2127524d9..168fe0a10cbd7c41ef87a3693a4750fceaf171c9 100644
--- a/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx
+++ b/frontend/src/metabase/query_builder/components/dataref/SegmentPane.jsx
@@ -50,10 +50,9 @@ export default class SegmentPane extends Component {
         let query = this.props.query;
         // Add an aggregation so both aggregation and filter popovers aren't visible
         if (!Query.hasValidAggregation(query.query)) {
-            Query.updateAggregation(query.query, ["rows"]);
+            Query.clearAggregations(query.query);
         }
-        Query.addFilter(query.query);
-        Query.updateFilter(query.query, Query.getFilters(query.query).length - 1, ["SEGMENT", this.props.segment.id]);
+        Query.addFilter(query.query, ["SEGMENT", this.props.segment.id]);
         this.props.setQueryFn(query);
         this.props.runQueryFn();
     }
diff --git a/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx b/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx
index 917abc90cf4bd81f6e68d442410dce10f811a530..e106d5a8676a20834b2dd0bb7246084131514e8f 100644
--- a/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx
+++ b/frontend/src/metabase/query_builder/components/dataref/TablePane.jsx
@@ -27,7 +27,7 @@ export default class TablePane extends Component {
         query: PropTypes.object.isRequired,
         loadTableAndForeignKeysFn: PropTypes.func.isRequired,
         show: PropTypes.func.isRequired,
-        closeFn: PropTypes.func.isRequired,
+        onClose: PropTypes.func.isRequired,
         setCardAndRun: PropTypes.func.isRequired,
         table: PropTypes.object
     };
diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
index 7926bff32d067416ff8a1be12ba6b66d807af1a8..d7099a9d5f57a4590873b245350c6cf43b5e5169 100644
--- a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
+++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
@@ -5,48 +5,55 @@ import S from "./ExpressionEditorTextfield.css";
 import _ from "underscore";
 import cx from "classnames";
 
+import { compile, suggest } from "metabase/lib/expressions/parser";
+import { format } from "metabase/lib/expressions/formatter";
+import { setCaretPosition, getSelectionPosition } from "metabase/lib/dom";
+
 import Popover from "metabase/components/Popover.jsx";
 
-import { parseExpressionString, tokenAtPosition, tokensToExpression, formatExpression, isExpression } from "metabase/lib/expressions";
+import TokenizedInput from "./TokenizedInput.jsx";
+
+import { isExpression } from "metabase/lib/expressions";
 
 
-const KEYCODE_TAB   =  9;
 const KEYCODE_ENTER = 13;
+const KEYCODE_ESC   = 27;
+const KEYCODE_LEFT  = 37;
 const KEYCODE_UP    = 38;
+const KEYCODE_RIGHT = 39;
 const KEYCODE_DOWN  = 40;
 
-
-// return the first token with a non-empty error message
-function getErrorToken(tokens) {
-    for (var i = 0; i < tokens.length; i++) {
-        let token = tokens[i];
-        if (token.error && token.error.length) return token;
-        if (!token.isParent) continue;
-        let childError = getErrorToken(token.value);
-        if (childError) return childError;
-    }
-    return null;
-}
-
+const MAX_SUGGESTIONS = 30;
 
 export default class ExpressionEditorTextfield extends Component {
     constructor(props, context) {
         super(props, context);
-        _.bindAll(this, 'onInputChange', 'onInputKeyDown', 'onInputBlur', 'onSuggestionAccepted', 'onSuggestionMouseDown');
+        _.bindAll(this, '_triggerAutosuggest', 'onInputKeyDown', 'onInputBlur', 'onSuggestionAccepted', 'onSuggestionMouseDown');
     }
 
     static propTypes = {
         expression: PropTypes.array,      // should be an array like [parsedExpressionObj, expressionString]
         tableMetadata: PropTypes.object.isRequired,
+        customFields: PropTypes.object,
         onChange: PropTypes.func.isRequired,
-        onError: PropTypes.func.isRequired
+        onError: PropTypes.func.isRequired,
+        startRule: PropTypes.string.isRequired
     };
 
     static defaultProps = {
         expression: [null, ""],
+        startRule: "expression",
         placeholder: "write some math!"
     }
 
+    _getParserInfo(props = this.props) {
+        return {
+            tableMetadata: props.tableMetadata,
+            customFields: props.customFields || {},
+            startRule: props.startRule
+        }
+    }
+
     componentWillMount() {
         this.componentWillReceiveProps(this.props);
     }
@@ -54,135 +61,178 @@ export default class ExpressionEditorTextfield extends Component {
     componentWillReceiveProps(newProps) {
         // we only refresh our state if we had no previous state OR if our expression or table has changed
         if (!this.state || this.props.expression != newProps.expression || this.props.tableMetadata != newProps.tableMetadata) {
-            let parsedExpression = newProps.expression,
-                expression       = formatExpression(newProps.expression, this.props.tableMetadata.fields),
-                tokens           = [];
-
-            let errorMessage = null;
+            const parserInfo = this._getParserInfo(newProps);
+            let parsedExpression = newProps.expression;
+            let expressionString = format(newProps.expression, parserInfo);
+            let expressionErrorMessage = null;
+            let suggestions = [];
             try {
-                tokens = expression && expression.length ? tokensToExpression(parseExpressionString(expression, newProps.tableMetadata.fields)) : [];
+                if (expressionString) {
+                    compile(expressionString, parserInfo);
+                }
             } catch (e) {
-                errorMessage = e;
+                expressionErrorMessage = e;
             }
 
             this.setState({
-                parsedExpression:       parsedExpression,
-                expressionString:       expression,
-                tokens:                 tokens,
-                expressionErrorMessage: errorMessage,
-                suggestions:            [],
-                highlightedSuggestion:  0,
-                suggestionsTitle:       null
+                parsedExpression,
+                expressionString,
+                expressionErrorMessage,
+                suggestions,
+                highlightedSuggestion: 0
             });
         }
     }
 
-    onSuggestionAccepted() {
-        let inputElement = ReactDOM.findDOMNode(this.refs.input),
-            displayName  = this.state.suggestions[this.state.highlightedSuggestion].display_name,
-            // wrap field names with spaces in them in quotes
-            needsQuotes  = displayName.indexOf(' ') > -1,
-            suggestion   = needsQuotes ? ('"' + displayName + '"') : displayName,
-            tokenAtPoint = tokenAtPosition(this.state.tokens, inputElement.selectionStart);
+    componentDidMount() {
+        this._setCaretPosition(this.state.expressionString.length, this.state.expressionString.length === 0)
+    }
 
-        let expression = this.state.expressionString.substring(0, tokenAtPoint.start) + suggestion + this.state.expressionString.substring(tokenAtPoint.end, this.state.expressionString.length);
+    onSuggestionAccepted() {
+        const { expressionString } = this.state;
+        const suggestion = this.state.suggestions[this.state.highlightedSuggestion];
 
-        // Remove extra quotation marks in case we accidentally inserted duplicates when accepting a suggestion already inside some
-        expression = expression.replace(/"+/, '"');
+        if (suggestion) {
+            let prefix = expressionString.slice(0, suggestion.index);
+            if (suggestion.prefixTrim) {
+                prefix = prefix.replace(suggestion.prefixTrim, "");
+            }
+            let postfix = expressionString.slice(suggestion.index);
+            if (suggestion.postfixTrim) {
+                postfix = postfix.replace(suggestion.postfixTrim, "");
+            }
+            if (!postfix && suggestion.postfixText) {
+                postfix = suggestion.postfixText;
+            }
 
-        // hand off to the code that deals with text change events which will trigger parsing and new autocomplete suggestions
-        inputElement.value = expression + ' ';
-        this.onInputChange(); // add a blank space after end of token
+            this.onExpressionChange(prefix + suggestion.text + postfix)
+            setTimeout(() => this._setCaretPosition((prefix + suggestion.text).length, true))
+        }
 
         this.setState({
             highlightedSuggestion: 0
         });
     }
 
-    onSuggestionMouseDown(event) {
+    onSuggestionMouseDown(event, index) {
         // when a suggestion is clicked, we'll highlight the clicked suggestion and then hand off to the same code that deals with ENTER / TAB keydowns
         event.preventDefault();
-
-        this.setState({
-            highlightedSuggestion: parseInt(event.target.getAttribute('data-i'))
-        }, this.onSuggestionAccepted);
+        event.stopPropagation();
+        this.setState({ highlightedSuggestion: index }, this.onSuggestionAccepted);
     }
 
-    onInputKeyDown(event) {
-        if (!this.state.suggestions.length) return;
+    onInputKeyDown(e) {
+        const { suggestions, highlightedSuggestion } = this.state;
 
-        if (event.keyCode === KEYCODE_ENTER || event.keyCode === KEYCODE_TAB) {
-            this.onSuggestionAccepted();
+        if (e.keyCode === KEYCODE_LEFT || e.keyCode === KEYCODE_RIGHT) {
+            setTimeout(() => this._triggerAutosuggest());
+            return;
+        }
+        if (e.keyCode === KEYCODE_ESC) {
+            e.stopPropagation();
+            e.preventDefault();
+            this.clearSuggestions();
+            return;
+        }
 
-        } else if (event.keyCode === KEYCODE_UP) {
+        if (!suggestions.length) {
+            return;
+        }
+        if (e.keyCode === KEYCODE_ENTER) {
+            this.onSuggestionAccepted();
+            e.preventDefault();
+        } else if (e.keyCode === KEYCODE_UP) {
             this.setState({
-                highlightedSuggestion: this.state.highlightedSuggestion === 0 ? (this.state.suggestions.length - 1) : (this.state.highlightedSuggestion - 1)
+                highlightedSuggestion: (highlightedSuggestion + suggestions.length - 1) % suggestions.length
             });
-        } else if (event.keyCode === KEYCODE_DOWN) {
+            e.preventDefault();
+        } else if (e.keyCode === KEYCODE_DOWN) {
             this.setState({
-                highlightedSuggestion: this.state.highlightedSuggestion === (this.state.suggestions.length - 1) ? 0 : (this.state.highlightedSuggestion + 1)
+                highlightedSuggestion: (highlightedSuggestion + suggestions.length + 1) % suggestions.length
             });
-        } else return;
-
-        event.preventDefault();
+            e.preventDefault();
+        }
     }
 
-    onInputBlur() {
+    clearSuggestions() {
         this.setState({
             suggestions: [],
-            highlightedSuggestion: 0,
-            suggestionsTitle: null
+            highlightedSuggestion: 0
         });
-
-        // whenever our input blurs we push the updated expression to our parent if valid
-        if (isExpression(this.state.parsedExpression)) this.props.onChange(this.state.parsedExpression)
-            else if (this.state.expressionErrorMessage)    this.props.onError(this.state.expressionErrorMessage);
     }
 
-    onInputChange() {
-        let inputElement = ReactDOM.findDOMNode(this.refs.input),
-            expression   = inputElement.value;
+    onInputBlur() {
+        this.clearSuggestions();
 
-        var errorMessage          = null,
-            tokens                = [],
-            suggestions           = [],
-            suggestionsTitle      = null,
-            highlightedSuggestion = this.state.highlightedSuggestion,
-            parsedExpression;
+        // whenever our input blurs we push the updated expression to our parent if valid
+        if (isExpression(this.state.parsedExpression)) {
+            this.props.onChange(this.state.parsedExpression);
+        } else if (this.state.expressionErrorMessage) {
+            this.props.onError(this.state.expressionErrorMessage);
+        } else {
+            this.props.onError({ message: "Invalid expression" });
+        }
+    }
 
-        try {
-            tokens = parseExpressionString(expression, this.props.tableMetadata.fields);
+    onInputClick = () => {
+        this._triggerAutosuggest();
+    }
 
-            let errorToken = getErrorToken(tokens);
-            if (errorToken) errorMessage = errorToken.error;
+    _triggerAutosuggest = () => {
+        this.onExpressionChange(this.state.expressionString);
+    }
 
-            let cursorPosition = inputElement.selectionStart;
-            let tokenAtPoint = tokenAtPosition(tokens, cursorPosition);
+    _setCaretPosition = (position, autosuggest) => {
+        setCaretPosition(ReactDOM.findDOMNode(this.refs.input), position);
+        if (autosuggest) {
+            setTimeout(() => this._triggerAutosuggest());
+        }
+    }
 
-            if (tokenAtPoint && tokenAtPoint.suggestions) {
-                suggestions = tokenAtPoint.suggestions;
-                suggestionsTitle = tokenAtPoint.suggestionsTitle;
-            }
+    onExpressionChange(expressionString) {
+        let inputElement = ReactDOM.findDOMNode(this.refs.input);
+        if (!inputElement) {
+            return;
+        }
 
-            if (highlightedSuggestion >= suggestions.length) highlightedSuggestion = suggestions.length - 1;
-            if (highlightedSuggestion < 0)                   highlightedSuggestion = 0;
+        const parserInfo = this._getParserInfo();
 
-            parsedExpression = tokensToExpression(tokens);
+        let expressionErrorMessage = null;
+        let suggestions           = [];
+        let parsedExpression;
 
+        try {
+            parsedExpression = compile(expressionString, parserInfo)
         } catch (e) {
-            errorMessage = e;
+            expressionErrorMessage = e;
+            console.error("expression error:", expressionErrorMessage);
         }
 
-        if (errorMessage) console.error('expression error message:', errorMessage);
+        const isValid = parsedExpression && parsedExpression.length > 0;
+        const [selectionStart, selectionEnd] = getSelectionPosition(inputElement);
+        const hasSelection = selectionStart !== selectionEnd;
+        const isAtEnd = selectionEnd === expressionString.length;
+        const endsWithWhitespace = /\s$/.test(expressionString);
+
+        // don't show suggestions if
+        // * there's a section
+        // * we're at the end of a valid expression, unless the user has typed another space
+        if (!hasSelection && !(isValid && isAtEnd && !endsWithWhitespace)) {
+            try {
+                suggestions = suggest(expressionString, {
+                    ...parserInfo,
+                    index: selectionEnd
+                })
+            } catch (e) {
+                console.error("suggest error:", e);
+            }
+        }
 
         this.setState({
-            expressionErrorMessage: errorMessage,
-            expressionString: expression,
-            parsedExpression: parsedExpression,
-            suggestions: suggestions,
-            suggestionsTitle: suggestionsTitle,
-            highlightedSuggestion: highlightedSuggestion,
-            tokens: tokens
+            expressionErrorMessage,
+            expressionString,
+            parsedExpression,
+            suggestions
         });
     }
 
@@ -191,48 +241,63 @@ export default class ExpressionEditorTextfield extends Component {
         if (errorMessage && !errorMessage.length) errorMessage = 'unknown error';
 
         const { placeholder } = this.props;
+        const { suggestions } = this.state;
 
         return (
             <div className={cx(S.editor, "relative")}>
-                <input
+                <TokenizedInput
                     ref="input"
-                    className={cx(S.input, "my1 p1 input block full h4 text-dark")}
+                    className={cx(S.input, "my1 input block full", { "border-error": errorMessage })}
                     type="text"
                     placeholder={placeholder}
                     value={this.state.expressionString}
-                    onChange={this.onInputChange}
+                    onChange={(e) => this.onExpressionChange(e.target.value)}
                     onKeyDown={this.onInputKeyDown}
                     onBlur={this.onInputBlur}
-                    onFocus={this.onInputChange}
-                    focus={true}
+                    onFocus={(e) => this._triggerAutosuggest()}
+                    onClick={this.onInputClick}
+                    autoFocus
+                    parserInfo={this._getParserInfo()}
                 />
                 <div className={cx(S.equalSign, "spread flex align-center h4 text-dark", { [S.placeholder]: !this.state.expressionString })}>=</div>
-                {this.state.suggestions.length ?
-                 <Popover
-                     className="p2 not-rounded border-dark"
-                     hasArrow={false}
-                     tetherOptions={{
-                             attachment: 'top left',
-                             targetAttachment: 'bottom left',
-                             targetOffset: '0 ' + ((this.state.expressionString.length / 2) * 6)
-                         }}
-                 >
-                     <div style={{minWidth: 150, maxHeight: 342, overflow: "hidden"}}>
-                         <h5 style={{marginBottom: 2}} className="h6 text-grey-2">{this.state.suggestionsTitle}</h5>
-                         <ul>
-                             {this.state.suggestions.map((suggestion, i) =>
-                                 <li style={{paddingTop: "2px", paddingBottom: "2px", cursor: "pointer"}}
-                                     className={cx({"text-bold text-brand": i === this.state.highlightedSuggestion})}
-                                     data-i={i}
-                                     onMouseDown={this.onSuggestionMouseDown}
-                                 >
-                                     {suggestion.display_name}
-                                 </li>
-                              )}
-                         </ul>
-                     </div>
-                 </Popover>
-                 : null}
+                { suggestions.length ?
+                    <Popover
+                        className="pb1 not-rounded border-dark"
+                        hasArrow={false}
+                        tetherOptions={{
+                            attachment: 'top left',
+                            targetAttachment: 'bottom left'
+                        }}
+                    >
+                        <ul style={{minWidth: 150, overflow: "hidden"}}>
+                            {suggestions.slice(0,MAX_SUGGESTIONS).map((suggestion, i) =>
+                                // insert section title. assumes they're sorted by type
+                                [(i === 0 || suggestion.type !== suggestions[i - 1].type) &&
+                                    <li  ref={"header-" + i} className="mx2 h6 text-uppercase text-bold text-grey-3 py1 pt2">
+                                        {suggestion.type}
+                                    </li>
+                                ,
+                                    <li ref={i} style={{ paddingTop: 5, paddingBottom: 5 }}
+                                        className={cx("px2 cursor-pointer text-white-hover bg-brand-hover", {"text-white bg-brand": i === this.state.highlightedSuggestion})}
+                                        onMouseDownCapture={(e) => this.onSuggestionMouseDown(e, i)}
+                                    >
+                                        { suggestion.prefixLength ?
+                                            <span>
+                                                <span className={cx("text-brand text-bold", {"text-white bg-brand": i === this.state.highlightedSuggestion})}>{suggestion.name.slice(0, suggestion.prefixLength)}</span>
+                                                <span>{suggestion.name.slice(suggestion.prefixLength)}</span>
+                                            </span>
+                                        :
+                                            suggestion.name
+                                        }
+                                    </li>
+                                ]
+                            )}
+                            { suggestions.length >= MAX_SUGGESTIONS &&
+                                <li style={{ paddingTop: 5, paddingBottom: 5 }} className="px2 text-italic text-grey-3">and {suggestions.length - MAX_SUGGESTIONS} more</li>
+                            }
+                        </ul>
+                    </Popover>
+                : null}
             </div>
         );
     }
diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx
index 6db2d44e51cd499dc5b62b37e1f75e7719a7e112..19ebf2437291eb2ad60e2d23939d8b3b0b788c7f 100644
--- a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx
+++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.jsx
@@ -42,7 +42,7 @@ export default class ExpressionWidget extends Component {
         const { expression } = this.state;
 
         return (
-            <div style={{maxWidth: "500px"}}>
+            <div style={{maxWidth: "600px"}}>
                 <div className="p2">
                     <div className="h5 text-uppercase text-grey-3 text-bold">Field formula</div>
                     <div>
@@ -62,7 +62,7 @@ export default class ExpressionWidget extends Component {
                     <div className="mt3 h5 text-uppercase text-grey-3 text-bold">Give it a name</div>
                     <div>
                         <input
-                            className="my1 p1 input block full h4 text-dark"
+                            className="my1 input block full"
                             type="text"
                             value={this.state.name}
                             placeholder="Something nice and descriptive"
diff --git a/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx b/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx
index ac7436ab92d081d0e2a936b4e09825ca9fe18efc..f2a1360e225c7b68c4a95b8d613c12d3eb6135f7 100644
--- a/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx
+++ b/frontend/src/metabase/query_builder/components/expressions/Expressions.jsx
@@ -5,7 +5,7 @@ import Icon from "metabase/components/Icon.jsx";
 import IconBorder from "metabase/components/IconBorder.jsx";
 import Tooltip from "metabase/components/Tooltip.jsx";
 
-import { formatExpression } from "metabase/lib/expressions";
+import { format } from "metabase/lib/expressions/formatter";
 
 
 export default class Expressions extends Component {
@@ -32,7 +32,10 @@ export default class Expressions extends Component {
                 { sortedNames && sortedNames.map(name =>
                     <div key={name} className="pb1 text-brand text-bold cursor-pointer flex flex-row align-center justify-between" onClick={() => onEditExpression(name)}>
                         <span>{name}</span>
-                        <Tooltip tooltip={formatExpression(expressions[name], this.props.tableMetadata.fields)}>
+                        <Tooltip tooltip={format(expressions[name], {
+                            tableMetadata: this.props.tableMetadata,
+                            customFields: expressions
+                        })}>
                             <span className="QuestionTooltipTarget" />
                         </Tooltip>
                     </div>
diff --git a/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.css b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.css
new file mode 100644
index 0000000000000000000000000000000000000000..70547cf26f5924218665972dc41276ad4a3f4f30
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.css
@@ -0,0 +1,49 @@
+.Expression-node {
+  /* this is required to get top/bottom padding to work, but breaks some keyboard shortcuts */
+  /*display: inline-block;*/
+  border-radius: 3px;
+  font-size: 14px;
+}
+
+.Expression-open-quote,
+.Expression-close-quote {
+  opacity: 0.5;
+}
+
+.Expression-aggregation {
+  padding: 3px 3px;
+}
+.Expression-aggregation-name {
+  padding: 0 2px;
+}
+
+.Expression-metric,
+.Expression-field {
+  margin: 1px 1px;
+  padding: 1px 3px;
+}
+
+.Expression-aggregation,
+.Expression-metric {
+  border: 1px solid #9CC177;
+  background-color: #E4F7D1;
+}
+
+.Expression-field {
+  border: 1px solid #509EE3;
+  background-color: #C7E3FB;
+}
+
+.Expression-selected.Expression-aggregation,
+.Expression-selected.Expression-metric,
+.Expression-selected .Expression-aggregation,
+.Expression-selected .Expression-metric {
+  color: white;
+  background-color: #9CC177;
+}
+
+.Expression-selected.Expression-field,
+.Expression-selected .Expression-field {
+  color: white;
+  background-color: #509EE3;
+}
diff --git a/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.jsx b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..66163c0abab7079247c339def3aced5bc98549d7
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.jsx
@@ -0,0 +1,108 @@
+import React, { Component, PropTypes } from "react";
+
+import "./TokenizedExpression.css";
+
+import cx from "classnames";
+
+export default class TokenizedExpression extends React.Component {
+    render() {
+        // TODO: use the Chevrotain parser or tokenizer
+        // let parsed = parse(this.props.source, this.props.parserInfo);
+        const parsed = parse(this.props.source);
+        return renderSyntaxTree(parsed);
+    }
+}
+
+const renderSyntaxTree = (node, index) =>
+    <span key={index} className={cx("Expression-node", "Expression-" + node.type, { "Expression-tokenized": node.tokenized })}>
+        {node.text != null ?
+            node.text
+        : node.children ?
+            node.children.map(renderSyntaxTree)
+        : null }
+    </span>
+
+
+function nextNonWhitespace(tokens, index) {
+    while (index < tokens.length && /^\s+$/.test(tokens[++index])) {
+    }
+    return tokens[index];
+}
+
+function parse(expressionString) {
+    let tokens = (expressionString || " ").match(/[a-zA-Z]\w*|"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"|\(|\)|\d+|\s+|[*/+-]|.+/g);
+
+    let root = { type: "group", children: [] };
+    let current = root;
+    let outsideAggregation = true;
+    const stack = [];
+    const push = (element) => {
+        current.children.push(element);
+        stack.push(current);
+        current = element;
+    }
+    const pop = () => {
+        if (stack.length === 0) {
+            return;
+        }
+        current = stack.pop();
+    }
+    for (let i = 0; i < tokens.length; i++) {
+        let token = tokens[i];
+        if (/^[a-zA-Z]\w*$/.test(token)) {
+            if (nextNonWhitespace(tokens, i) === "(") {
+                outsideAggregation = false;
+                push({
+                    type: "aggregation",
+                    tokenized: true,
+                    children: []
+                });
+                current.children.push({
+                    type: "aggregation-name",
+                    text: token
+                });
+            } else {
+                current.children.push({
+                    type: outsideAggregation ? "metric" : "field",
+                    tokenized: true,
+                    text: token
+                });
+            }
+        } else if (/^"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"$/.test(token)) {
+            current.children.push({
+                type: "string-literal",
+                tokenized: true,
+                children: [
+                    { type: "open-quote", text: "\"" },
+                    { type: outsideAggregation ? "metric" : "field", text: JSON.parse(token) },
+                    { type: "close-quote", text: "\"" }
+                ]
+            });
+        } else if (token === "(") {
+            push({ type: "group", children: [] })
+            current.children.push({ type: "open-paren", text: "(" })
+        } else if (token === ")") {
+            current.children.push({ type: "close-paren", text: ")" })
+            pop();
+            if (current.type === "aggregation") {
+                outsideAggregation = true;
+                pop();
+            }
+        } else {
+            // special handling for unclosed string literals
+            if (i === tokens.length - 1 && /^".+[^"]$/.test(token)) {
+                current.children.push({
+                    type: "string-literal",
+                    tokenized: true,
+                    children: [
+                        { type: "open-quote", text: "\"" },
+                        { type: outsideAggregation ? "metric" : "field", text: JSON.parse(token + "\"") }
+                    ]
+                });
+            } else {
+                current.children.push({ type: "token", text: token });
+            }
+        }
+    }
+    return root;
+}
diff --git a/frontend/src/metabase/query_builder/components/expressions/TokenizedInput.jsx b/frontend/src/metabase/query_builder/components/expressions/TokenizedInput.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d02dfdcc2bb84b18cafe7d176a3a52b2a3ac7371
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/TokenizedInput.jsx
@@ -0,0 +1,148 @@
+import React, { Component, PropTypes } from "react";
+import ReactDOM from "react-dom";
+
+import TokenizedExpression from "./TokenizedExpression.jsx";
+
+import { getCaretPosition, saveSelection, getSelectionPosition } from "metabase/lib/dom"
+
+
+const KEYCODE_BACKSPACE      = 8;
+const KEYCODE_LEFT           = 37;
+const KEYCODE_RIGHT          = 39;
+const KEYCODE_FORWARD_DELETE = 46;
+
+export default class TokenizedInput extends Component {
+    constructor(props) {
+        super(props);
+        this.state = {
+            value: ""
+        }
+    }
+
+    _getValue() {
+        if (this.props.value != undefined) {
+            return this.props.value;
+        } else {
+            return this.state.value;
+        }
+    }
+    _setValue(value) {
+        ReactDOM.findDOMNode(this).value = value;
+        if (typeof this.props.onChange === "function") {
+            this.props.onChange({ target: { value }});
+        } else {
+            this.setState({ value });
+        }
+    }
+
+    componentDidMount() {
+        ReactDOM.findDOMNode(this).focus();
+        this.componentDidUpdate()
+
+        document.addEventListener("selectionchange", this.onSelectionChange, false);
+    }
+    componentWillUnmount() {
+        document.removeEventListener("selectionchange", this.onSelectionChange, false);
+    }
+    onSelectionChange = (e) => {
+        ReactDOM.findDOMNode(this).selectionStart = getCaretPosition(ReactDOM.findDOMNode(this))
+    }
+    onClick = (e) => {
+        this._isTyping = false;
+        return this.props.onClick(e);
+    }
+    onInput = (e) => {
+        this._setValue(e.target.textContent);
+    }
+    onKeyDown = (e) => {
+        // isTyping signals whether the user is typing characters (keyCode >= 65) vs. deleting / navigating with arrows / clicking to select
+        const isTyping = this._isTyping;
+        // also keep isTyping same when deleting
+        this._isTyping = e.keyCode >= 65 || (e.keyCode === KEYCODE_BACKSPACE && isTyping);
+
+        const input = ReactDOM.findDOMNode(this);
+
+        let [start, end] = getSelectionPosition(input);
+        if (start !== end) {
+            return;
+        }
+
+        let element = window.getSelection().focusNode;
+        while (element && element !== input) {
+            // check ancestors of the focused node for "Expression-tokenized"
+            // if the element is marked as "tokenized" we might want to intercept keypresses
+            if (element.classList && element.classList.contains("Expression-tokenized")) {
+                const positionInElement = getCaretPosition(element);
+                const atStart = positionInElement === 0;
+                const atEnd = positionInElement === element.textContent.length;
+                const isSelected = element.classList.contains("Expression-selected");
+                if (!isSelected && !isTyping && (
+                    atEnd && e.keyCode === KEYCODE_BACKSPACE ||
+                    atStart && e.keyCode === KEYCODE_FORWARD_DELETE
+                )) {
+                    // not selected, not "typging", and hit backspace, so mark as "selected"
+                    element.classList.add("Expression-selected");
+                    e.stopPropagation();
+                    e.preventDefault();
+                    return;
+                } else if (isSelected && (
+                    atEnd && e.keyCode === KEYCODE_BACKSPACE ||
+                    atStart && e.keyCode === KEYCODE_FORWARD_DELETE
+                )) {
+                    // selected and hit backspace, so delete it
+                    element.parentNode.removeChild(element);
+                    this._setValue(input.textContent);
+                    e.stopPropagation();
+                    e.preventDefault();
+                    return;
+                } else if (isSelected && (
+                    atEnd && e.keyCode === KEYCODE_LEFT ||
+                    atStart && e.keyCode === KEYCODE_RIGHT
+                )) {
+                    // selected and hit left arrow, so enter "typing" mode and unselect it
+                    element.classList.remove("Expression-selected");
+                    this._isTyping = true;
+                    e.stopPropagation();
+                    e.preventDefault();
+                    return;
+                }
+            }
+            // nada, try the next ancestor
+            element = element.parentNode;
+        }
+
+        // if we haven't handled the event yet, pass it on to our parent
+        this.props.onKeyDown(e);
+    }
+
+    componentDidUpdate() {
+        const inputNode = ReactDOM.findDOMNode(this);
+        const restore = saveSelection(inputNode);
+
+        ReactDOM.unmountComponentAtNode(inputNode);
+        while (inputNode.firstChild) {
+            inputNode.removeChild(inputNode.firstChild);
+        }
+        ReactDOM.render(<TokenizedExpression source={this._getValue()} parserInfo={this.props.parserInfo} />, inputNode);
+
+        if (document.activeElement === inputNode) {
+            restore();
+        }
+    }
+
+    render() {
+        const { className, onFocus, onBlur } = this.props;
+        return (
+            <div
+                className={className}
+                style={{ whiteSpace: "pre-wrap" }}
+                contentEditable
+                onKeyDown={this.onKeyDown}
+                onInput={this.onInput}
+                onFocus={onFocus}
+                onBlur={onBlur}
+                onClick={this.onClick}
+            />
+        );
+    }
+}
diff --git a/frontend/src/metabase/query_builder/components/filters/FilterList.jsx b/frontend/src/metabase/query_builder/components/filters/FilterList.jsx
index 81998d41df1d21b0e1fb49f10bbbd691471e5573..7899db34ff5958f3bd19c20fdf90c6c7585be969 100644
--- a/frontend/src/metabase/query_builder/components/filters/FilterList.jsx
+++ b/frontend/src/metabase/query_builder/components/filters/FilterList.jsx
@@ -35,13 +35,13 @@ export default class FilterList extends Component {
         const { filters, tableMetadata } = this.props;
         return (
             <div className="Query-filterList scroll-x scroll-show scroll-show--horizontal">
-                {filters.slice(1).map((filter, index) =>
+                {filters.map((filter, index) =>
                     <FilterWidget
                         key={index}
                         placeholder="Item"
                         filter={filter}
                         tableMetadata={tableMetadata}
-                        index={index+1}
+                        index={index}
                         removeFilter={this.props.removeFilter}
                         updateFilter={this.props.updateFilter}
                         maxDisplayValues={this.props.maxDisplayValues}
diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx
index b97632aa76b31575b791ce0c239e02fa71f49c8e..0a492020d0c8d166fd4f53d2ad6a511af88d7978 100644
--- a/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx
+++ b/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx
@@ -62,7 +62,7 @@ class CurrentPicker extends Component {
 }
 
 
-const getIntervals = ([op, field, value, unit]) => op === "TIME_INTERVAL" && typeof value === "number" ? Math.abs(value) : 1;
+const getIntervals = ([op, field, value, unit]) => op === "TIME_INTERVAL" && typeof value === "number" ? Math.abs(value) : 30;
 const getUnit      = ([op, field, value, unit]) => op === "TIME_INTERVAL" && unit ? unit : "day";
 const getDate      = (value) => typeof value === "string" && moment(value).isValid() ? value : moment().format("YYYY-MM-DD");
 
@@ -105,7 +105,7 @@ const OPERATORS = [
     },
     {
         name: "Between",
-        init: (filter) => ["BETWEEN", filter[1], null, null],
+        init: (filter) => ["BETWEEN", filter[1], getDate(filter[2]), getDate(filter[3])],
         test: ([op]) => op === "BETWEEN",
         widget: MultiDatePicker,
     },
diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx
index f08fbe43f6da07cf3e285c1aed2fbb68d1053bea..c66a5d47f4c7c904245aea3e588ad26095ac04d6 100644
--- a/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx
+++ b/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx
@@ -36,6 +36,7 @@ export default class RelativeDatePicker extends Component {
             <div className="px2">
                 <NumericInput
                     className="input h3 mb2 border-purple"
+                    data-ui-tag="relative-date-input"
                     value={typeof intervals === "number" ? Math.abs(intervals) : intervals}
                     onChange={(value) =>
                         onFilterChange([op, field, formatter(value), unit])
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index 7b6dddf3135ed10f2aa0a6bd7e6e26d7a55835a7..91acb2ef441099dff484ebdec9bb4e4452431470 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -221,7 +221,7 @@ export default class QueryBuilder extends Component {
 
                 <div className={cx("SideDrawer", { "SideDrawer--show": showDrawer })}>
                     { uiControls.isShowingDataReference &&
-                        <DataReference {...this.props} closeFn={() => this.props.toggleDataReference()} />
+                        <DataReference {...this.props} onClose={() => this.props.toggleDataReference()} />
                     }
 
                     { uiControls.isShowingTemplateTagsEditor &&
diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js
index 8fa4c20c93308ac1be4e93d8b103ab02c08c8582..3da2998a7ec2f2680a7f89022514503251c2ca3c 100644
--- a/frontend/src/metabase/query_builder/selectors.js
+++ b/frontend/src/metabase/query_builder/selectors.js
@@ -7,6 +7,7 @@ import { getTemplateTags } from "metabase/meta/Card";
 import { isCardDirty, isCardRunnable } from "metabase/lib/card";
 import { parseFieldTarget } from "metabase/lib/query_time";
 import { isPK } from "metabase/lib/types";
+import Query from "metabase/lib/query";
 
 export const uiControls                = state => state.qb.uiControls;
 
@@ -93,9 +94,7 @@ export const isObjectDetail = createSelector(
 	    if (dataset_query.query &&
 	            dataset_query.query.source_table &&
 	            dataset_query.query.filter &&
-	            dataset_query.query.aggregation &&
-	            dataset_query.query.aggregation.length > 0 &&
-	            dataset_query.query.aggregation[0] === "rows" &&
+				Query.isBareRows(dataset_query.query) &&
 	            data.rows &&
 	            data.rows.length === 1) {
 
@@ -110,8 +109,7 @@ export const isObjectDetail = createSelector(
 
 	        // now check that we have a filter clause w/ '=' filter on PK column
 	        if (pkField !== undefined) {
-	            for (var j=0; j < dataset_query.query.filter.length; j++) {
-	                let filter = dataset_query.query.filter[j];
+	            for (const filter of Query.getFilters(dataset_query.query)) {
 	                if (Array.isArray(filter) &&
 	                        filter.length === 3 &&
 	                        filter[0] === "=" &&
diff --git a/frontend/src/metabase/questions/collections.js b/frontend/src/metabase/questions/collections.js
new file mode 100644
index 0000000000000000000000000000000000000000..119f1b28aeb09d46ed5337f10d6e5b1427bab00a
--- /dev/null
+++ b/frontend/src/metabase/questions/collections.js
@@ -0,0 +1,104 @@
+
+import { createAction, createThunkAction, handleActions, combineReducers } from "metabase/lib/redux";
+import { reset } from 'redux-form';
+import { push } from "react-router-redux";
+import Urls from "metabase/lib/urls";
+
+import _ from "underscore";
+
+import MetabaseAnalytics from "metabase/lib/analytics";
+
+import { CollectionsApi } from "metabase/services";
+
+export const LOAD_COLLECTION = 'metabase/collections/LOAD_COLLECTION';
+export const LOAD_COLLECTIONS = 'metabase/collections/LOAD_COLLECTIONS';
+export const SAVE_COLLECTION = 'metabase/collections/SAVE_COLLECTION';
+export const DELETE_COLLECTION = 'metabase/collections/DELETE_COLLECTION';
+export const SET_COLLECTION_ARCHIVED = 'metabase/collections/SET_COLLECTION_ARCHIVED';
+
+export const loadCollection = createAction(LOAD_COLLECTION, (id) => CollectionsApi.get({ id }));
+export const loadCollections = createAction(LOAD_COLLECTIONS, CollectionsApi.list);
+
+export const saveCollection = createThunkAction(SAVE_COLLECTION, (collection) => {
+    return async (dispatch, getState) => {
+        try {
+            if (!collection.description) {
+                // description must be nil or non empty string
+                collection = { ...collection, description: null }
+            }
+            let response;
+            if (collection.id == null) {
+                MetabaseAnalytics.trackEvent("Collections", "Create");
+                response = await CollectionsApi.create(collection);
+            } else {
+                MetabaseAnalytics.trackEvent("Collections", "Update");
+                response = await CollectionsApi.update(collection);
+            }
+            if (response.id != null) {
+                dispatch(reset("collection"));
+            }
+            dispatch(push(Urls.collection(response)));
+            return response;
+        } catch (e) {
+            // redux-form expects an object with either { field: error } or { _error: error }
+            if (e.data && e.data.errors) {
+                throw e.data.errors;
+            } else if (e.data && e.data.message) {
+                throw { _error: e.data.message };
+            } else {
+                throw { _error: "An unknown error occured" };
+            }
+        }
+    }
+});
+
+export const setCollectionArchived = createThunkAction(SET_COLLECTION_ARCHIVED, (id, archived) =>
+    async (dispatch, getState) => {
+        MetabaseAnalytics.trackEvent("Collections", "Set Archived", archived);
+        // HACK: currently the only way to archive/unarchive a collection is to PUT it along with name/description/color, so grab it from the list
+        const collection = _.findWhere(await CollectionsApi.list({ archived: !archived }), { id });
+        return await CollectionsApi.update({ ...collection, archived: archived });
+    }
+);
+
+export const deleteCollection = createThunkAction(DELETE_COLLECTION, (id) =>
+    async (dispatch, getState) => {
+        try {
+            MetabaseAnalytics.trackEvent("Collections", "Delete");
+            await CollectionsApi.delete({ id });
+            return id;
+        } catch (e) {
+            // TODO: handle error
+            return null;
+        }
+    }
+);
+
+const collections = handleActions({
+    [LOAD_COLLECTIONS]:  { next: (state, { payload }) => payload },
+    [SAVE_COLLECTION]:   { next: (state, { payload }) => state.filter(c => c.id !== payload.id).concat(payload) },
+    [DELETE_COLLECTION]: { next: (state, { payload }) => state.filter(c => c.id !== payload) },
+    [SET_COLLECTION_ARCHIVED]: { next: (state, { payload }) => state.filter(c => c.id !== payload.id) }
+}, []);
+
+const error = handleActions({
+    [SAVE_COLLECTION]: {
+        next: (state) => null,
+        throw: (state, { error }) => error
+    }
+}, null);
+
+const collection = handleActions({
+    [LOAD_COLLECTION]: {
+        next: (state, { payload }) => payload
+    },
+    [SAVE_COLLECTION]: {
+        next: (state, { payload }) => payload
+    }
+}, null);
+
+export default combineReducers({
+    collection,
+    collections,
+    error
+});
diff --git a/frontend/src/metabase/questions/components/ActionHeader.css b/frontend/src/metabase/questions/components/ActionHeader.css
index 4e9cba46df6974cae00838ff2896445578882ef1..ab0909c39fce44aee7506b975a50a5db4daf18f4 100644
--- a/frontend/src/metabase/questions/components/ActionHeader.css
+++ b/frontend/src/metabase/questions/components/ActionHeader.css
@@ -4,7 +4,7 @@
     composes: full flex align-center from "style";
     line-height: 1;
     font-size: 14px;
-    color: var(--blue-color);
+    color: var(--brand-color);
 }
 
 :local(.selectedCount) {
diff --git a/frontend/src/metabase/questions/components/ActionHeader.jsx b/frontend/src/metabase/questions/components/ActionHeader.jsx
index acfd73a6558f3d20e6b66178d7349b6d0b0ed698..041e8cf186daea51db034576691c5504c0b40ad9 100644
--- a/frontend/src/metabase/questions/components/ActionHeader.jsx
+++ b/frontend/src/metabase/questions/components/ActionHeader.jsx
@@ -5,18 +5,18 @@ import S from "./ActionHeader.css";
 import StackedCheckBox from "metabase/components/StackedCheckBox.jsx";
 import Icon from "metabase/components/Icon.jsx";
 import Tooltip from "metabase/components/Tooltip.jsx";
+import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx";
+import MoveToCollection from "../containers/MoveToCollection.jsx";
 
 import LabelPopover from "../containers/LabelPopover.jsx";
 
-import cx from "classnames";
-
 const ActionHeader = ({ visibleCount, selectedCount, allAreSelected, sectionIsArchive, setAllSelected, setArchived, labels }) =>
     <div className={S.actionHeader}>
         <Tooltip tooltip={"Select all " + visibleCount} isEnabled={!allAreSelected}>
             <StackedCheckBox
                 checked={allAreSelected}
+                className="ml1"
                 onChange={(e) => setAllSelected(e.target.checked)}
-                className={cx(S.allCheckbox, { [S.selected]: allAreSelected })}
                 size={20} padding={3} borderColor="currentColor"
                 invertChecked
             />
@@ -25,7 +25,7 @@ const ActionHeader = ({ visibleCount, selectedCount, allAreSelected, sectionIsAr
             {selectedCount} selected
         </span>
         <span className="flex align-center flex-align-right">
-            { !sectionIsArchive ?
+            { !sectionIsArchive && labels.length > 0 ?
                 <LabelPopover
                     triggerElement={
                         <span className={S.actionButton}>
@@ -38,8 +38,19 @@ const ActionHeader = ({ visibleCount, selectedCount, allAreSelected, sectionIsAr
                     count={selectedCount}
                 />
             : null }
+            <ModalWithTrigger
+                full
+                triggerElement={
+                    <span className={S.actionButton} >
+                        <Icon name="move" className="mr1" />
+                        Move
+                    </span>
+                }
+            >
+                <MoveToCollection />
+            </ModalWithTrigger>
             <span className={S.actionButton} onClick={() => setArchived(undefined, !sectionIsArchive, true)}>
-                <Icon name={ sectionIsArchive ? "unarchive" : "archive" } />
+                <Icon name={ sectionIsArchive ? "unarchive" : "archive" }  className="mr1" />
                 { sectionIsArchive ? "Unarchive" : "Archive" }
             </span>
         </span>
diff --git a/frontend/src/metabase/questions/components/ArchivedItem.jsx b/frontend/src/metabase/questions/components/ArchivedItem.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bcfe9200e77f7525f589ed616cafb90fa572aeda
--- /dev/null
+++ b/frontend/src/metabase/questions/components/ArchivedItem.jsx
@@ -0,0 +1,37 @@
+/* eslint "react/prop-types": "warn" */
+
+import React, { PropTypes } from "react";
+
+import Icon from "metabase/components/Icon";
+import Tooltip from "metabase/components/Tooltip";
+
+const ArchivedItem = ({ name, type, icon, color = '#DEEAF1', isAdmin = false, onUnarchive }) =>
+    <div className="flex align-center p2 hover-parent hover--visibility border-bottom bg-grey-0-hover">
+        <Icon
+            name={icon}
+            className="mr2"
+            style={{ color: color }}
+            size={20}
+        />
+        { name }
+        { isAdmin &&
+            <Tooltip tooltip={`Unarchive this ${type === "card" ? "question" : type}`}>
+                <Icon
+                    onClick={onUnarchive}
+                    className="ml-auto cursor-pointer text-brand-hover hover-child"
+                    name="unarchive"
+                />
+            </Tooltip>
+        }
+    </div>
+
+ArchivedItem.propTypes = {
+    name:        PropTypes.string.isRequired,
+    type:        PropTypes.string.isRequired,
+    icon:        PropTypes.string.isRequired,
+    color:       PropTypes.string,
+    isAdmin:     PropTypes.bool,
+    onUnarchive: PropTypes.func.isRequired
+}
+
+export default ArchivedItem;
diff --git a/frontend/src/metabase/questions/components/CollectionActions.jsx b/frontend/src/metabase/questions/components/CollectionActions.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bbecfd2290d86eaa8822f00606d50474f39649b5
--- /dev/null
+++ b/frontend/src/metabase/questions/components/CollectionActions.jsx
@@ -0,0 +1,12 @@
+import React from "react";
+
+const CollectionActions = ({ children }) =>
+    <div onClick={(e) => { e.stopPropagation(); e.preventDefault() }}>
+        {React.Children.map(children, (child, index) =>
+            <span key={index} className="cursor-pointer text-brand-hover mx1">
+                {child}
+            </span>
+        )}
+    </div>
+
+export default CollectionActions;
diff --git a/frontend/src/metabase/questions/components/CollectionBadge.jsx b/frontend/src/metabase/questions/components/CollectionBadge.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..64c74dc075d80b25509bf0bd8b1156ea238275ca
--- /dev/null
+++ b/frontend/src/metabase/questions/components/CollectionBadge.jsx
@@ -0,0 +1,22 @@
+import React, { Component, PropTypes } from "react";
+import { Link } from "react-router";
+
+import Urls from "metabase/lib/urls";
+
+import Color from "color";
+import cx from "classnames";
+
+const CollectionBadge = ({ className, collection }) =>
+    <Link
+        to={Urls.collection(collection)}
+        className={cx(className, "flex align-center px1 rounded mx1")}
+        style={{
+            fontSize: 14,
+            color: Color(collection.color).darken(0.1).hexString(),
+            backgroundColor: Color(collection.color).lighten(0.4).hexString()
+        }}
+    >
+        {collection.name}
+    </Link>
+
+export default CollectionBadge;
diff --git a/frontend/src/metabase/questions/components/CollectionButtons.jsx b/frontend/src/metabase/questions/components/CollectionButtons.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a27c6f4a61b0b6af4d53f2ad3a2d6dabfaa58d6b
--- /dev/null
+++ b/frontend/src/metabase/questions/components/CollectionButtons.jsx
@@ -0,0 +1,100 @@
+import React, { Component, PropTypes } from "react";
+import { Link } from "react-router";
+import cx from "classnames";
+
+import Icon from "metabase/components/Icon";
+import ArchiveCollectionWidget from "../containers/ArchiveCollectionWidget";
+
+const COLLECTION_ICON_SIZE = 64;
+
+const COLLECTION_BOX_CLASSES = "relative block p4 hover-parent hover--visibility cursor-pointer text-centered transition-background";
+
+const CollectionButtons = ({ collections, isAdmin, push }) =>
+    <ol className="flex flex-wrap">
+        { collections
+            .map(collection => <CollectionButton {...collection} push={push} isAdmin={isAdmin} />)
+            .concat(isAdmin ? [<NewCollectionButton push={push} />] : [])
+            .map((element, index) =>
+                <li key={index} className="mr4 mb4">
+                    {element}
+                </li>
+            )
+        }
+    </ol>
+
+class CollectionButton extends Component {
+    constructor() {
+        super();
+        this.state = { hovered: false };
+    }
+
+    render () {
+        const { id, name, color, slug, isAdmin } = this.props;
+        return (
+            <Link
+                to={`/questions/collections/${slug}`}
+                className="no-decoration"
+                onMouseEnter={() => this.setState({ hovered: true })}
+                onMouseLeave={() => this.setState({ hovered: false })}
+            >
+                <div
+                    className={cx(COLLECTION_BOX_CLASSES, 'text-white-hover')}
+                    style={{
+                        width: 290,
+                        height: 180,
+                        borderRadius: 10,
+                        backgroundColor: this.state.hovered ? color : '#fafafa'
+                    }}
+                >
+                    { isAdmin &&
+                        <div className="absolute top right mt2 mr2 hover-child">
+                            <Link to={"/collections/permissions?collectionId=" + id} className="mr1">
+                                <Icon name="lockoutline" tooltip="Set collection permissions" />
+                            </Link>
+                            <ArchiveCollectionWidget collectionId={id} />
+                        </div>
+                    }
+                    <Icon
+                        className="mb2 mt2"
+                        name="collection"
+                        size={COLLECTION_ICON_SIZE}
+                        style={{ color: this.state.hovered ? '#fff' : color }}
+                    />
+                    <h3>{ name }</h3>
+                </div>
+            </Link>
+        )
+    }
+}
+
+const NewCollectionButton = ({ push }) =>
+    <div
+        className={cx(COLLECTION_BOX_CLASSES, 'bg-brand-hover', 'text-brand', 'text-white-hover', 'bg-grey-0')}
+        style={{
+            width: 290,
+            height: 180,
+            borderRadius: 10
+        }}
+        onClick={() => push(`/collections/create`)}
+    >
+        <div>
+            <div
+                className="flex align-center justify-center ml-auto mr-auto mb2 mt2"
+                style={{
+                    border: '2px solid #D8E8F5',
+                    borderRadius: COLLECTION_ICON_SIZE,
+                    height: COLLECTION_ICON_SIZE,
+                    width: COLLECTION_ICON_SIZE,
+                }}
+            >
+                <Icon
+                    name="add"
+                    width="32"
+                    height="32"
+                />
+            </div>
+        </div>
+        <h3>New collection</h3>
+    </div>
+
+export default CollectionButtons;
diff --git a/frontend/src/metabase/questions/components/ExpandingSearchField.jsx b/frontend/src/metabase/questions/components/ExpandingSearchField.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..51ae5c303dad0d1ddefafb41b9fb0155addf4aac
--- /dev/null
+++ b/frontend/src/metabase/questions/components/ExpandingSearchField.jsx
@@ -0,0 +1,104 @@
+/* eslint "react/prop-types": "warn" */
+
+import React, { Component, PropTypes } from "react";
+import ReactDOM from "react-dom";
+
+import cx from "classnames";
+import { Motion, spring } from "react-motion";
+
+import Icon from "metabase/components/Icon";
+
+const KEYCODE_FORWARD_SLASH = 191; // focus search
+const KEYCODE_ESCAPE = 27; // blur search
+const KEYCODE_ENTER = 13; // execute search
+
+export default class ExpandingSearchField extends Component {
+    constructor (props, context) {
+        super(props, context);
+        this.state = {
+            active: false
+        };
+    }
+
+    static propTypes = {
+        onSearch: PropTypes.func.isRequired,
+        className: PropTypes.string,
+        defaultValue: PropTypes.string,
+    }
+
+    componentDidMount () {
+        this.listenToSearchKeyDown();
+    }
+
+    componentWillUnMount () {
+        this.stopListenToSearchKeyDown();
+    }
+
+    handleSearchKeydown = (e) => {
+        if (!this.state.active && e.keyCode === KEYCODE_FORWARD_SLASH) {
+            this.setActive();
+            e.preventDefault();
+        }
+    }
+
+    onKeyPress = (e) => {
+        if (e.keyCode === KEYCODE_ENTER) {
+            this.props.onSearch(e.target.value)
+        } else if (e.keyCode === KEYCODE_ESCAPE) {
+            this.setInactive();
+        }
+    }
+
+    setActive = () => {
+        ReactDOM.findDOMNode(this.searchInput).focus();
+    }
+
+    setInactive = () => {
+        ReactDOM.findDOMNode(this.searchInput).blur();
+    }
+
+    listenToSearchKeyDown () {
+        window.addEventListener('keydown', this.handleSearchKeydown);
+    }
+
+    stopListenToSearchKeyDown() {
+        window.removeEventListener('keydown', this.handleSearchKeydown);
+    }
+
+    render () {
+        const { className } = this.props;
+        const { active } = this.state;
+        return (
+            <div
+                className={cx(
+                    className,
+                    'bordered border-dark flex align-center pr2 transition-border',
+                    { 'border-brand' : active }
+                )}
+                onClick={this.setActive}
+                style={{borderRadius: 99}}
+            >
+                <Icon
+                    className={cx('ml2', { 'text-brand': active })}
+                    name="search"
+                />
+                <Motion
+                    style={{width: active ? spring(400) : spring(200) }}
+                >
+                    { interpolatingStyle =>
+                        <input
+                            ref={(search) => this.searchInput = search}
+                            className="input text-bold borderless"
+                            placeholder="Search for a question..."
+                            style={Object.assign({}, interpolatingStyle, { fontSize: '1em'})}
+                            onFocus={() => this.setState({ active: true })}
+                            onBlur={() => this.setState({ active: false })}
+                            onKeyUp={this.onKeyPress}
+                            defaultValue={this.props.defaultValue}
+                        />
+                    }
+                </Motion>
+            </div>
+        );
+    }
+}
diff --git a/frontend/src/metabase/questions/components/Item.jsx b/frontend/src/metabase/questions/components/Item.jsx
index a4f0c5cd4c202631ac8974fb06b8491aec762df6..b8f5003d4fd7c20809de6f5504c96cc032442fd3 100644
--- a/frontend/src/metabase/questions/components/Item.jsx
+++ b/frontend/src/metabase/questions/components/Item.jsx
@@ -1,92 +1,175 @@
 /* eslint "react/prop-types": "warn" */
 import React, { Component, PropTypes } from "react";
 import { Link } from "react-router";
-import S from "./List.css";
+import cx from "classnames";
+import pure from "recompose/pure";
 
-import Labels from "./Labels.jsx";
-import LabelPopover from "../containers/LabelPopover.jsx";
+import S from "./List.css";
 
 import Icon from "metabase/components/Icon.jsx";
 import CheckBox from "metabase/components/CheckBox.jsx";
 import Tooltip from "metabase/components/Tooltip.jsx";
+import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx";
+import MoveToCollection from "../containers/MoveToCollection.jsx";
+import Labels from "./Labels.jsx";
+import CollectionBadge from "./CollectionBadge.jsx";
 
 import Urls from "metabase/lib/urls";
 
-import cx from "classnames";
-import pure from "recompose/pure";
+const ITEM_ICON_SIZE = 20;
 
-const Item = ({ id, name, created, by, selected, favorite, archived, icon, labels, setItemSelected, setFavorited, setArchived }) =>
-    <div className={cx(S.item, { [S.selected]: selected, [S.favorite]: favorite, [S.archived]: archived })}>
-        <div className={S.leftIcons}>
-            { icon && <Icon className={S.chartIcon} name={icon} size={20} /> }
-            <CheckBox
-                checked={selected}
-                onChange={(e) => setItemSelected({ [id]: e.target.checked })}
-                className={S.itemCheckbox}
-                size={20}
-                padding={3}
-                borderColor="currentColor"
-                invertChecked
+const Item = ({
+    entity,
+    id, name, description, labels, created, by, favorite, collection, archived,
+    icon, selected, setItemSelected, setFavorited, setArchived, showCollectionName,
+    onEntityClick
+}) =>
+    <div className={cx('hover-parent hover--visibility', S.item)}>
+        <div className="flex flex-full align-center">
+            <div className="relative flex ml1 mr2" style={{ width: ITEM_ICON_SIZE, height: ITEM_ICON_SIZE }}>
+                { icon &&
+                    <Icon
+                        className={cx("text-light-blue absolute top left visible", { "hover-child--hidden": !!setItemSelected })}
+                        name={icon}
+                        size={ITEM_ICON_SIZE}
+                    />
+                }
+                { setItemSelected &&
+                    <CheckBox
+                        className={cx(
+                            "cursor-pointer absolute top left",
+                            { "visible text-brand": selected },
+                            { "hover-child text-brand-hover text-light-blue transition-color": !selected }
+                        )}
+                        checked={selected}
+                        onChange={(e) => setItemSelected({ [id]: e.target.checked })}
+                        size={ITEM_ICON_SIZE}
+                        padding={3}
+                        borderColor="currentColor"
+                        invertChecked
+                    />
+                }
+            </div>
+            <ItemBody
+                entity={entity}
+                id={id}
+                name={name}
+                description={description}
+                labels={labels}
+                favorite={favorite}
+                collection={showCollectionName && collection}
+                setFavorited={setFavorited}
+                onEntityClick={onEntityClick}
             />
         </div>
-        <ItemBody id={id} name={name} labels={labels} created={created} by={by} />
-        { !archived ?
-            <div className={S.rightIcons}>
-                <LabelPopover
-                    triggerElement={
-                        <Tooltip tooltip={"Labels"}>
-                            <Icon className={S.tagIcon} name="label" size={20} />
-                        </Tooltip>
-                    }
-                    triggerClasses={S.trigger}
-                    triggerClassesOpen={S.open}
-                    item={{ id, labels }}
-                />
-                <Tooltip tooltip={favorite ? "Unfavorite" : "Favorite"}>
-                    <Icon className={S.favoriteIcon} name="star" size={20} onClick={() => setFavorited(id, !favorite) }/>
-                </Tooltip>
-            </div>
-        : null }
-        <div className={S.extraIcons}>
-            <Tooltip tooltip={archived ? "Unarchive" : "Archive"}>
-                <Icon className={S.archiveIcon} name={ archived ? "unarchive" : "archive"} size={20} onClick={() => setArchived(id, !archived, true)} />
-            </Tooltip>
+        <div className="flex flex-column ml-auto">
+            <ItemCreated
+                by={by}
+                created={created}
+            />
+            { setArchived &&
+                <div className="hover-child mt1 ml-auto">
+                    <ModalWithTrigger
+                        full
+                        triggerElement={
+                            <Tooltip tooltip="Move to a collection">
+                                <Icon
+                                    className="text-light-blue cursor-pointer text-brand-hover transition-color mx2"
+                                    name="move"
+                                    size={18}
+                                />
+                            </Tooltip>
+                        }
+                    >
+                        <MoveToCollection
+                            questionId={id}
+                            initialCollectionId={collection && collection.id}
+                        />
+                    </ModalWithTrigger>
+                    <Tooltip tooltip={archived ? "Unarchive" : "Archive"}>
+                        <Icon
+                            className="text-light-blue cursor-pointer text-brand-hover transition-color"
+                            name={ archived ? "unarchive" : "archive"}
+                            onClick={() => setArchived(id, !archived, true)}
+                            size={18}
+                        />
+                    </Tooltip>
+                </div>
+            }
         </div>
     </div>
 
 Item.propTypes = {
+    entity:             PropTypes.object.isRequired,
     id:                 PropTypes.number.isRequired,
     name:               PropTypes.string.isRequired,
     created:            PropTypes.string.isRequired,
+    description:        PropTypes.string,
     by:                 PropTypes.string.isRequired,
+    labels:             PropTypes.array.isRequired,
+    collection:         PropTypes.object,
     selected:           PropTypes.bool.isRequired,
     favorite:           PropTypes.bool.isRequired,
     archived:           PropTypes.bool.isRequired,
     icon:               PropTypes.string.isRequired,
-    labels:             PropTypes.array.isRequired,
     setItemSelected:    PropTypes.func.isRequired,
     setFavorited:       PropTypes.func.isRequired,
     setArchived:        PropTypes.func.isRequired,
+    onEntityClick:      PropTypes.func,
+    showCollectionName: PropTypes.bool,
 };
 
-const ItemBody = pure(({ id, name, labels, created, by }) =>
+const ItemBody = pure(({ entity, id, name, description, labels, favorite, collection, setFavorited, onEntityClick }) =>
     <div className={S.itemBody}>
-        <div className={S.itemTitle}>
-            <Link to={Urls.card(id)} className={S.itemName}>{name}</Link>
+        <div className={cx('flex', S.itemTitle)}>
+            <Link to={Urls.card(id)} className={cx(S.itemName)} onClick={onEntityClick && ((e) => { e.preventDefault(); onEntityClick(entity); })}>
+                {name}
+            </Link>
+            { collection &&
+                <CollectionBadge collection={collection} />
+            }
+            { favorite != null && setFavorited &&
+                <Tooltip tooltip={favorite ? "Unfavorite" : "Favorite"}>
+                    <Icon
+                        className={cx(
+                            "flex cursor-pointer text-brand-hover transition-color",
+                            {"hover-child text-light-blue": !favorite},
+                            {"visible text-brand": favorite}
+                        )}
+                        name={favorite ? "star" : "staroutline"}
+                        size={ITEM_ICON_SIZE}
+                        onClick={() => setFavorited(id, !favorite) }
+                    />
+                </Tooltip>
+            }
             <Labels labels={labels} />
         </div>
-        <div className={cx(S.itemSubtitle, { "mt1" : labels.length === 0 })}>
-          {`Created ${created} by ${by}`}
+        <div className={cx({ 'text-slate': description }, { 'text-light-blue': !description })}>
+            {description ? description : "No description yet"}
         </div>
     </div>
 );
 
 ItemBody.propTypes = {
+    description:        PropTypes.string,
+    favorite:           PropTypes.bool.isRequired,
     id:                 PropTypes.number.isRequired,
     name:               PropTypes.string.isRequired,
+    setFavorited:       PropTypes.func.isRequired,
+};
+
+const ItemCreated = pure(({ created, by }) =>
+    (created || by) ?
+        <div className={S.itemSubtitle}>
+            {"Created" + (created ? ` ${created}` : ``) + (by ? ` by ${by}` : ``)}
+        </div>
+    :
+        null
+);
+
+ItemCreated.propTypes = {
     created:            PropTypes.string.isRequired,
     by:                 PropTypes.string.isRequired,
-    labels:             PropTypes.array.isRequired,
 };
 
 export default pure(Item);
diff --git a/frontend/src/metabase/questions/components/LabelPicker.css b/frontend/src/metabase/questions/components/LabelPicker.css
index f4b7d81045a3dc7c83f252838a547da66ccfb6df..97aa2b4a856748441d43f159017d22c368f6f0f2 100644
--- a/frontend/src/metabase/questions/components/LabelPicker.css
+++ b/frontend/src/metabase/questions/components/LabelPicker.css
@@ -11,6 +11,11 @@
     font-size: 14px;
 }
 
+:local(.footer) {
+    composes: text-bold p2 border-top from "style";
+    font-size: 14px;
+}
+
 :local(.options) {
     composes: full-height py1 scroll-y from "style";
     max-height: 300px;
diff --git a/frontend/src/metabase/questions/components/LabelPicker.jsx b/frontend/src/metabase/questions/components/LabelPicker.jsx
index 89db8b9bc79346646cc1b0687fb6b9c31458245b..36d6645ea2c20a57a53ff8e9ae1113deb4d6c671 100644
--- a/frontend/src/metabase/questions/components/LabelPicker.jsx
+++ b/frontend/src/metabase/questions/components/LabelPicker.jsx
@@ -1,9 +1,12 @@
 /* eslint "react/prop-types": "warn" */
 import React, { Component, PropTypes } from "react";
+import { Link } from "react-router";
+
 import S from "./LabelPicker.css";
 
 import LabelIcon from "metabase/components/LabelIcon.jsx";
 import Icon from "metabase/components/Icon.jsx";
+import Tooltip from "metabase/components/Tooltip.jsx";
 
 import cx from "classnames";
 
@@ -44,6 +47,12 @@ const LabelPicker = ({ labels, count, item, setLabeled }) =>
                 )
             }) }
         </ul>
+        <div className={S.footer}>
+            <Link className="link" to="/labels">Add and edit labels</Link>
+            <Tooltip tooltip="In an upcoming release, Labels will be removed in favor of Collections.">
+                <Icon name="warning2" className="text-error float-right" />
+            </Tooltip>
+        </div>
     </div>
 
 LabelPicker.propTypes = {
diff --git a/frontend/src/metabase/questions/components/Labels.jsx b/frontend/src/metabase/questions/components/Labels.jsx
index e14b3d8bc08b26f7b72d28aa33af9204f3dba412..5ae520ce03096ffb10f506eaa28e00f58b6ec609 100644
--- a/frontend/src/metabase/questions/components/Labels.jsx
+++ b/frontend/src/metabase/questions/components/Labels.jsx
@@ -3,6 +3,7 @@ import React, { Component, PropTypes } from "react";
 import { Link } from "react-router";
 import S from "./Labels.css";
 import color from 'color'
+import Urls from "metabase/lib/urls";
 
 import EmojiIcon from "metabase/components/EmojiIcon.jsx"
 
@@ -33,7 +34,7 @@ class Label extends Component {
     const { hovered } = this.state
     return (
       <Link
-        to={"/questions/label/"+slug}
+        to={Urls.label({ slug })}
         onMouseEnter={() => this.setState({ hovered: true })}
         onMouseLeave={() => this.setState({ hovered: false })}
       >
diff --git a/frontend/src/metabase/questions/components/List.css b/frontend/src/metabase/questions/components/List.css
index a9dabc12c3e10823bff7f07c1fcbd60ab37c83e8..70663604962afcd0fa67767ee1599b45bbcc870b 100644
--- a/frontend/src/metabase/questions/components/List.css
+++ b/frontend/src/metabase/questions/components/List.css
@@ -1,7 +1,6 @@
 @import '../Questions.css';
 
 :local(.list) {
-  max-width: var(--md-width);
   composes: ml-auto mr-auto from "style";
   padding-bottom: 40px;
 }
@@ -39,7 +38,6 @@
 
 :local(.itemTitle) {
     composes: text-bold from "style";
-    composes: inline-block from "style";
     color: var(--title-color);
     font-size: 18px;
 }
@@ -62,97 +60,9 @@
     color: var(--title-color);
 }
 
-:local(.icons) {
-    composes: flex flex-row align-center from "style";
-}
-:local(.leftIcons) {
-    composes: icons;
-    width: 50px;
-}
-:local(.rightIcons) {
-    composes: icons;
-}
-
-:local(.extraIcons) {
-    composes: icons;
-    composes: absolute top full-height from "style";
-    right: -40px;
-}
-
-/* hack fix for IE 11 which was hiding the archive icon */
-@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
-  :local(.extraIcons) {
-      composes: icons;
-  }
-}
-
-:local(.icon) {
-    composes: relative cursor-pointer from "style";
-    color: var(--muted-color);
-}
-
-:local(.item) :local(.icon) {
-    visibility: hidden;
-}
-:local(.item):hover :local(.icon) {
-    visibility: visible;
-}
-:local(.icon):hover {
-    color: var(--blue-color);
-    transition: color .3s linear;
-}
-
-/* ITEM CHECKBOX */
-:local(.itemCheckbox) {
-    composes: icon;
-    display: none;
-    visibility: visible !important;
-    margin-left: 10px;
-}
-:local(.item):hover :local(.itemCheckbox),
-:local(.item.selected) :local(.itemCheckbox) {
-    display: inline;
-}
-:local(.item.selected) :local(.itemCheckbox) {
-    color: var(--blue-color);
-}
-
-/* CHART ICON */
-:local(.chartIcon) {
-    composes: icon;
-    visibility: visible !important;
-    composes: relative from "style";
-}
-:local(.item):hover :local(.chartIcon),
-:local(.item.selected) :local(.chartIcon) {
-    display: none;
-}
-
-/* ACTION ICONS */
-:local(.tagIcon),
-:local(.favoriteIcon),
-:local(.archiveIcon) {
-    composes: icon;
-    composes: mx1 from "style";
-}
-
 /* TAG */
 :local(.open) :local(.tagIcon) {
     visibility: visible;
     color: var(--blue-color);
 }
 
-/* FAVORITE */
-:local(.item.favorite) :local(.favoriteIcon) {
-    visibility: visible;
-    color: var(--blue-color);
-}
-
-/* ARCHIVE */
-:local(.item.archived) :local(.archiveIcon) {
-    color: var(--blue-color);
-}
-
-:local(.trigger) {
-    line-height: 0;
-}
diff --git a/frontend/src/metabase/questions/components/List.jsx b/frontend/src/metabase/questions/components/List.jsx
index 8195dc28f193e059b1ba0c1ea4c631052ba95e87..a02ba7d094451095e2a219924479c5f576d1c3e6 100644
--- a/frontend/src/metabase/questions/components/List.jsx
+++ b/frontend/src/metabase/questions/components/List.jsx
@@ -1,22 +1,20 @@
 /* eslint "react/prop-types": "warn" */
 import React, { Component, PropTypes } from "react";
 
-import S from "../components/List.css";
+import S from "./List.css";
 import pure from "recompose/pure";
 
 import EntityItem from "../containers/EntityItem.jsx";
 
-const List = ({ entityType, entityIds, setItemSelected }) =>
+const List = ({ entityIds, ...props }) =>
     <ul className={S.list}>
         { entityIds.map(entityId =>
-            <EntityItem key={entityId} entityType={entityType} entityId={entityId} setItemSelected={setItemSelected} />
+            <EntityItem key={entityId} entityId={entityId} {...props} />
         )}
     </ul>
 
 List.propTypes = {
-    entityType:         PropTypes.string.isRequired,
     entityIds:          PropTypes.array.isRequired,
-    setItemSelected:    PropTypes.func.isRequired,
 };
 
 export default pure(List);
diff --git a/frontend/src/metabase/questions/components/SearchHeader.jsx b/frontend/src/metabase/questions/components/SearchHeader.jsx
index c59117b916bc61fc1a3e13b971e0ba4b714980ae..abf67231b4c3e7d5af25570b0c216f2146a5172d 100644
--- a/frontend/src/metabase/questions/components/SearchHeader.jsx
+++ b/frontend/src/metabase/questions/components/SearchHeader.jsx
@@ -12,7 +12,7 @@ const SearchHeader = ({ searchText, setSearchText }) =>
         <input
             className={cx("input", S.searchBox)}
             type="text"
-            placeholder="Search for a question..."
+            placeholder="Filter this list..."
             value={searchText}
             onChange={(e) => setSearchText(e.target.value)}
         />
diff --git a/frontend/src/metabase/questions/components/Sidebar.css b/frontend/src/metabase/questions/components/Sidebar.css
deleted file mode 100644
index 91fa7d24da2b986d87c41fbdd85355833fcf51ba..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/questions/components/Sidebar.css
+++ /dev/null
@@ -1,92 +0,0 @@
-:root {
-    --item-padding: 45px;
-}
-
-:local(.sidebar-padding) {
-  padding-left: var(--item-padding);
-  padding-right: var(--item-padding);
-}
-
-:local(.sidebar-margin) {
-  margin-left: var(--item-padding);
-  margin-right: var(--item-padding);
-}
-
-:local(.sidebar) {
-    composes: py2 from "style";
-    width: 345px;
-    background-color: rgb(248, 252, 253);
-    border-right: 1px solid rgb(223, 238, 245);
-    color: #606E7B;
-}
-
-:local(.sidebar) a {
-    text-decoration: none;
-}
-
-:local(.item),
-:local(.sectionTitle) {
-    composes: flex align-center from "style";
-    composes: py2 from "style";
-    composes: sidebar-padding;
-}
-
-:local(.item) {
-    composes: transition-color from "style";
-    composes: transition-background from "style";
-    font-size: 1em;
-    color: #CFE4F5;
-}
-
-:local(.item) :local(.icon) {
-  line-height: 1em;
-}
-
-:local(.sectionTitle) {
-    composes: my1 from "style";
-    composes: text-bold from "style";
-    font-size: 16px;
-}
-
-
-:local(.item.selected),
-:local(.item.selected) :local(.icon),
-:local(.sectionTitle.selected),
-:local(.item):hover,
-:local(.item):hover :local(.icon),
-:local(.sectionTitle):hover {
-    background-color: #E3F0F9;
-    color: #2D86D4;
-}
-
-:local(.divider) {
-    composes: my2 from "style";
-    composes: border-bottom from "style";
-    composes: sidebar-margin;
-}
-
-:local(.name) {
-    composes: ml2 text-bold from "style";
-    color: #9CAEBE;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    overflow-x: hidden;
-}
-
-:local(.item):hover :local(.name),
-:local(.item.selected) :local(.name) {
-    color: #2D86D4;
-}
-
-:local(.icon) {
-    composes: flex-no-shrink from "style";
-}
-
-
-:local(.noLabelsMessage) {
-  composes: relative from "style";
-  composes: text-centered from "style";
-  composes: p2 my3 from "style";
-  composes: text-brand-light from "style";
-  composes: sidebar-margin;
-}
diff --git a/frontend/src/metabase/questions/components/Sidebar.jsx b/frontend/src/metabase/questions/components/Sidebar.jsx
deleted file mode 100644
index d70b60b752d4003c892824817584c4414e240de6..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/questions/components/Sidebar.jsx
+++ /dev/null
@@ -1,76 +0,0 @@
-/* eslint "react/prop-types": "warn" */
-import React, { PropTypes } from "react";
-import { Link } from "react-router";
-import S from "./Sidebar.css";
-
-import Icon from "metabase/components/Icon.jsx";
-import LabelIcon from "metabase/components/LabelIcon.jsx";
-import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx";
-
-import cx from 'classnames';
-import pure from "recompose/pure";
-
-const Sidebar = ({ sections, labels, labelsLoading, labelsError, style, className }) =>
-    <div className={cx(S.sidebar, className)} style={style}>
-        <ul>
-            {sections.map(section =>
-                <QuestionSidebarItem key={section.id} href={"/questions/" + section.id} {...section} />
-            )}
-            <QuestionSidebarSectionTitle name="Labels" href="/questions/edit/labels" />
-        </ul>
-        <LoadingAndErrorWrapper loading={labelsLoading} error={labelsError} noBackground noWrapper>
-        { () => labels.length > 0 ? // eslint-disable-line
-            <ul>
-            { labels.map(label =>
-                <QuestionSidebarItem key={label.id} href={"/questions/label/"+label.slug} {...label} />
-            )}
-            </ul>
-        :
-            <div className={S.noLabelsMessage}>
-                <div>
-                  <Icon name="label" />
-                </div>
-                Create labels to group and manage questions.
-            </div>
-        }
-        </LoadingAndErrorWrapper>
-        <ul>
-            <li className={S.divider} />
-            <QuestionSidebarItem name="Archive" href="/questions/archived" icon="archive" />
-        </ul>
-    </div>
-
-Sidebar.propTypes = {
-    className:      PropTypes.string,
-    style:          PropTypes.object,
-    sections:       PropTypes.array.isRequired,
-    labels:         PropTypes.array.isRequired,
-    labelsLoading:  PropTypes.bool.isRequired,
-    labelsError:    PropTypes.any,
-};
-
-const QuestionSidebarSectionTitle = ({ name, href }) =>
-    <li>
-        <Link to={href} className={S.sectionTitle} activeClassName={S.selected}>{name}</Link>
-    </li>
-
-QuestionSidebarSectionTitle.propTypes = {
-    name:  PropTypes.string.isRequired,
-    href:  PropTypes.string.isRequired,
-};
-
-const QuestionSidebarItem = ({ name, icon, href }) =>
-    <li>
-        <Link to={href} className={S.item} activeClassName={S.selected}>
-            <LabelIcon className={S.icon} icon={icon}/>
-            <span className={S.name}>{name}</span>
-        </Link>
-    </li>
-
-QuestionSidebarItem.propTypes = {
-    name:  PropTypes.string.isRequired,
-    icon:  PropTypes.string.isRequired,
-    href:  PropTypes.string.isRequired,
-};
-
-export default pure(Sidebar);
diff --git a/frontend/src/metabase/questions/containers/AddToDashboard.jsx b/frontend/src/metabase/questions/containers/AddToDashboard.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d74f552179bc6de2244043ac7e66db97a86cf302
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/AddToDashboard.jsx
@@ -0,0 +1,113 @@
+import React, { Component } from "react";
+
+import Button from "metabase/components/Button.jsx";
+import ModalContent from "metabase/components/ModalContent.jsx";
+import Icon from "metabase/components/Icon.jsx";
+import HeaderWithBack from "metabase/components/HeaderWithBack";
+
+import Collections from "./CollectionList";
+import EntityList from "./EntityList";
+import ExpandingSearchField from "../components/ExpandingSearchField.jsx";
+
+export default class AddToDashboard extends Component {
+    constructor(props, context) {
+        super(props, context);
+
+        this.state = {
+            collection: null,
+            query: null
+        }
+    }
+
+    render() {
+        const { query, collection } = this.state;
+        return (
+            <ModalContent
+                title="Add question to dashboard?"
+                className="mx4 mb4"
+            >
+                <div className="py1 flex align-center">
+                    { !query ?
+                        <ExpandingSearchField
+                            defaultValue={query && query.q}
+                            onSearch={(value) => this.setState({
+                                collection: null,
+                                query: { q: value }
+                            })}
+                        />
+                    :
+                        <HeaderWithBack
+                            name={collection && collection.name}
+                            onBack={() => this.setState({ collection: null, query: null })}
+                        />
+                    }
+                    { query &&
+                        <div className="ml-auto flex align-center">
+                            <h5>Sort by</h5>
+                            <Button borderless>
+                                Last modified
+                            </Button>
+                            <Button borderless>
+                                Alphabetical order
+                            </Button>
+                        </div>
+                    }
+                </div>
+                { this.state.query ?
+                    <EntityList
+                        entityType="cards"
+                        entityQuery={this.state.query}
+                        editable={false}
+                        showSearchWidget={false}
+                        onEntityClick={this.props.onAdd}
+                    />
+                :
+                    <Collections>
+                        { collections =>
+                            <ol>
+                                { collections.map((collection, index) =>
+                                    <li
+                                        className="text-brand-hover flex align-center border-bottom cursor-pointer py1 mb1"
+                                        key={index}
+                                        onClick={() => this.setState({
+                                            collection: collection,
+                                            query: { collection: collection.slug }
+                                        })}
+                                    >
+                                        <Icon
+                                            className="mr2"
+                                            name="all"
+                                            style={{ color: collection.color }}
+                                        />
+                                        <h3>{collection.name}</h3>
+                                        <Icon
+                                            className="ml-auto"
+                                            name="chevronright"
+                                        />
+                                    </li>
+                                )}
+                                <li
+                                    className="text-brand-hover flex align-center border-bottom cursor-pointer py1 mb1"
+                                    onClick={() => this.setState({
+                                        collection: { name: "Everything else" },
+                                        query: { collection: "" }
+                                    })}
+                                >
+                                        <Icon
+                                            className="mr2"
+                                            name="star"
+                                        />
+                                        <h3>Everything else</h3>
+                                        <Icon
+                                            className="ml-auto"
+                                            name="chevronright"
+                                        />
+                                </li>
+                            </ol>
+                        }
+                    </Collections>
+                }
+            </ModalContent>
+        );
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/Archive.jsx b/frontend/src/metabase/questions/containers/Archive.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..595b0d1a2b777cae51ed8495e5e4c8d03f81ba19
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/Archive.jsx
@@ -0,0 +1,71 @@
+import React, { Component } from "react";
+import { connect } from "react-redux";
+
+import HeaderWithBack from "metabase/components/HeaderWithBack";
+import SearchHeader from "../components/SearchHeader";
+import ArchivedItem from "../components/ArchivedItem";
+
+import { loadEntities, setArchived, setSearchText } from "../questions";
+import { setCollectionArchived } from "../collections";
+import { getVisibleEntities, getSearchText } from "../selectors";
+import { getUserIsAdmin } from "metabase/selectors/user";
+
+import visualizations from "metabase/visualizations";
+
+const mapStateToProps = (state, props) => ({
+    searchText:             getSearchText(state, props),
+    archivedCards:          getVisibleEntities(state, { entityType: "cards", entityQuery: { f: "archived" }}) || [],
+    archivedCollections:    getVisibleEntities(state, { entityType: "collections", entityQuery: { archived: true }}) || [],
+
+    isAdmin:                getUserIsAdmin(state, props)
+})
+
+const mapDispatchToProps = {
+    loadEntities,
+    setSearchText,
+    setArchived,
+    setCollectionArchived
+}
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class Archive extends Component {
+    componentWillMount() {
+        this.loadEntities();
+    }
+    loadEntities() {
+        this.props.loadEntities("cards", { f: "archived" });
+        this.props.loadEntities("collections", { archived: true });
+    }
+    render () {
+        const { archivedCards, archivedCollections, isAdmin } = this.props;
+        const items = [
+            ...archivedCollections.map(collection => ({ type: "collection", ...collection })),
+            ...archivedCards.map(card => ({ type: "card", ...card }))
+        ]//.sort((a,b) => a.updated_at.valueOf() - b.updated_at.valueOf()))
+
+        return (
+            <div className="px4 pt3">
+                <div className="flex align-center mb2">
+                    <HeaderWithBack name="Archive" />
+                </div>
+                <SearchHeader searchText={this.props.searchText} setSearchText={this.props.setSearchText} />
+                <div>
+                    { items.map(item =>
+                        item.type === "collection" ?
+                            <ArchivedItem key={item.type + item.id} name={item.name} type="collection" icon="collection" color={item.color} isAdmin={isAdmin} onUnarchive={async () => {
+                                await this.props.setCollectionArchived(item.id, false);
+                                this.loadEntities()
+                            }} />
+                        : item.type === "card" ?
+                            <ArchivedItem key={item.type + item.id} name={item.name} type="card" icon={visualizations.get(item.display).iconName} isAdmin={isAdmin} onUnarchive={async () => {
+                                await this.props.setArchived(item.id, false, true);
+                                this.loadEntities();
+                            }} />
+                        :
+                            null
+                    )}
+                </div>
+            </div>
+        );
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx b/frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..63e94607239152cadd83a7460f73312604814767
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx
@@ -0,0 +1,59 @@
+import React, { Component, PropTypes } from "react";
+import { connect } from "react-redux";
+
+import ModalWithTrigger from "metabase/components/ModalWithTrigger";
+import Button from "metabase/components/Button";
+import Icon from "metabase/components/Icon";
+import Tooltip from "metabase/components/Tooltip";
+
+import { setCollectionArchived } from "../collections";
+
+const mapStateToProps = (state, props) => ({
+})
+
+const mapDispatchToProps = {
+    setCollectionArchived
+}
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class ArchiveCollectionWidget extends Component {
+    _onArchive = async () => {
+        try {
+            await this.props.setCollectionArchived(this.props.collectionId, true);
+            this._onClose();
+            if (this.props.onArchived) {
+                this.props.onArchived();
+            }
+        } catch (error) {
+            console.error(error)
+            this.setState({ error })
+        }
+    }
+
+    _onClose = () => {
+        if (this.refs.modal) {
+            this.refs.modal.close();
+        }
+    }
+
+    render() {
+        return (
+            <ModalWithTrigger
+                {...this.props}
+                ref="modal"
+                triggerElement={
+                    <Tooltip tooltip="Archive collection">
+                        <Icon name="archive" />
+                    </Tooltip>
+                }
+                title="Archive this collection?"
+                footer={[
+                    <Button onClick={this._onClose}>Cancel</Button>,
+                    <Button warning onClick={this._onArchive}>Archive</Button>
+                ]}
+            >
+                <div className="px4 pb4">The saved questions in this collection will also be archived.</div>
+            </ModalWithTrigger>
+        );
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/CollectionCreate.jsx b/frontend/src/metabase/questions/containers/CollectionCreate.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d9da2e02dd9fc1cf2e17af946b2677a1e71a36a5
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/CollectionCreate.jsx
@@ -0,0 +1,30 @@
+import React, { Component, PropTypes } from "react";
+
+import { connect } from "react-redux";
+import { push } from "react-router-redux";
+
+import CollectionEditorForm from "./CollectionEditorForm.jsx";
+
+import { saveCollection } from "../collections";
+
+const mapStateToProps = (state, props) => ({
+    error: state.collections.error,
+    collection: state.collections.collection,
+});
+
+const mapDispatchToProps = {
+    saveCollection,
+    onClose: () => push("/questions")
+}
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class CollectionEdit extends Component {
+    render() {
+        return (
+            <CollectionEditorForm
+                {...this.props}
+                onSubmit={this.props.saveCollection}
+            />
+        );
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/CollectionEdit.jsx b/frontend/src/metabase/questions/containers/CollectionEdit.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bf87420bc608b58715e444bf74e6c55ba9559260
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/CollectionEdit.jsx
@@ -0,0 +1,35 @@
+import React, { Component, PropTypes } from "react";
+
+import { connect } from "react-redux";
+import { goBack } from "react-router-redux";
+
+import CollectionEditorForm from "./CollectionEditorForm.jsx";
+
+import { saveCollection, loadCollection } from "../collections";
+
+const mapStateToProps = (state, props) => ({
+    error: state.collections.error,
+    collection: state.collections.collection,
+});
+
+const mapDispatchToProps = {
+    loadCollection,
+    saveCollection,
+    onClose: goBack
+}
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class CollectionEdit extends Component {
+    componentWillMount() {
+        this.props.loadCollection(this.props.params.collectionId);
+    }
+    render() {
+        return (
+            <CollectionEditorForm
+                {...this.props}
+                onSubmit={this.props.saveCollection}
+                initialValues={this.props.collection}
+            />
+        );
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/CollectionEditorForm.jsx b/frontend/src/metabase/questions/containers/CollectionEditorForm.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..4b530c66804580718b8866f3c5d15c818873f0c3
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/CollectionEditorForm.jsx
@@ -0,0 +1,80 @@
+import React, { Component } from "react";
+
+import Button from "metabase/components/Button";
+import ColorPicker from "metabase/components/ColorPicker";
+import FormField from "metabase/components/FormField";
+import Input from "metabase/components/Input";
+import Modal from "metabase/components/Modal";
+
+import { reduxForm } from "redux-form";
+
+@reduxForm({
+    form: 'collection',
+    fields: ['id', 'name', 'description', 'color'],
+    validate: (values) => {
+        const errors = {};
+        if (!values.name) {
+            errors.name = "Name is required";
+        } else if (values.name.length > 100) {
+            errors.name = "Name must be 100 characters or less";
+        }
+        if (!values.color) {
+            errors.color = "Color is required";
+        }
+        return errors;
+    },
+    initialValues: { name: "", description: "", color: "#509EE3" }
+})
+export default class CollectionEditorForm extends Component {
+    render() {
+        const { fields, handleSubmit, invalid, onClose } = this.props;
+        return (
+            <Modal
+                inline
+                form
+                title={fields.id.value != null ? fields.name.value : "New collection"}
+                footer={[
+                    <Button className="mr1" onClick={onClose}>
+                        Cancel
+                    </Button>,
+                    <Button primary disabled={invalid} onClick={handleSubmit}>
+                        { fields.id.value != null ? "Update" : "Create" }
+                    </Button>
+                ]}
+                onClose={onClose}
+            >
+                <div className="NewForm ml-auto mr-auto mt4 pt2" style={{ width: 540 }}>
+                    <FormField
+                        displayName="Name"
+                        {...fields.name}
+                    >
+                        <Input
+                            className="Form-input full"
+                            placeholder="My new fantastic collection"
+                            autoFocus
+                            {...fields.name}
+                        />
+                    </FormField>
+                    <FormField
+                        displayName="Description"
+                        {...fields.description}
+                    >
+                        <textarea
+                            className="Form-input full"
+                            placeholder="It's optional but oh, so helpful"
+                            {...fields.description}
+                        />
+                    </FormField>
+                    <FormField
+                        displayName="Color"
+                        {...fields.color}
+                    >
+                        <ColorPicker
+                            {...fields.color}
+                        />
+                    </FormField>
+                </div>
+            </Modal>
+        )
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/CollectionList.jsx b/frontend/src/metabase/questions/containers/CollectionList.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..649ae5051536a7b33df1c0b8497401ff9f73f341
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/CollectionList.jsx
@@ -0,0 +1,26 @@
+import React, { Component } from "react";
+import { connect } from "react-redux";
+
+import { getAllCollections, getWritableCollections } from "../selectors";
+import { loadCollections } from "../collections";
+
+const mapStateToProps = (state, props) => ({
+    collections: props.writable ? getWritableCollections(state, props) : getAllCollections(state, props)
+})
+
+const mapDispatchToProps = {
+    loadCollections
+}
+
+@connect(mapStateToProps, mapDispatchToProps)
+class Collections extends Component {
+    componentWillMount() {
+        this.props.loadCollections();
+    }
+    render () {
+        const collectionList = this.props.children(this.props.collections)
+        return collectionList && React.Children.only(collectionList);
+    }
+}
+
+export default Collections;
diff --git a/frontend/src/metabase/questions/containers/CollectionPage.jsx b/frontend/src/metabase/questions/containers/CollectionPage.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ee97c700ec86fc04e884bee96f934497e819bf16
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/CollectionPage.jsx
@@ -0,0 +1,73 @@
+import React, { Component, PropTypes } from "react";
+import { connect } from "react-redux";
+import { push, replace, goBack } from "react-router-redux";
+
+import Icon from "metabase/components/Icon";
+import HeaderWithBack from "metabase/components/HeaderWithBack";
+
+import CollectionActions from "../components/CollectionActions";
+import ArchiveCollectionWidget from "./ArchiveCollectionWidget";
+import EntityList from "./EntityList";
+import { loadCollections } from "../collections";
+
+import _ from "underscore";
+
+const mapStateToProps = (state, props) => ({
+    collection: _.findWhere(state.collections.collections, { slug: props.params.collectionSlug })
+})
+
+const mapDispatchToProps = ({
+    push,
+    replace,
+    goBack,
+    goToQuestions: () => push(`/questions`),
+    editCollection: (id) => push(`/collections/${id}`),
+    editPermissions: (id) => push(`/collections/permissions?collection=${id}`),
+    loadCollections,
+})
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class CollectionPage extends Component {
+    componentWillMount () {
+        this.props.loadCollections();
+    }
+    render () {
+        const { collection, params, location, push, replace, goBack } = this.props;
+        const canEdit = collection && collection.can_write;
+        return (
+            <div className="mx4 mt4">
+                <div className="flex align-center">
+                    <HeaderWithBack
+                        name={collection && collection.name}
+                        description={collection && collection.description}
+                        onBack={window.history.length === 1 ?
+                            () => push("/questions") :
+                            () => goBack()
+                        }
+                    />
+                    <div className="ml-auto">
+                        <CollectionActions>
+                            { canEdit && <ArchiveCollectionWidget collectionId={this.props.collection.id} onArchived={this.props.goToQuestions}/> }
+                            { canEdit && <Icon name="pencil" tooltip="Edit collection" onClick={() => this.props.editCollection(this.props.collection.id)} /> }
+                            { canEdit && <Icon name="lock" tooltip="Set permissions" onClick={() => this.props.editPermissions(this.props.collection.id)} /> }
+                        </CollectionActions>
+                    </div>
+                </div>
+                <div className="mt4">
+                    <EntityList
+                        defaultEmptyState="No questions have been added to this collection yet."
+                        entityType="cards"
+                        entityQuery={{ f: "all", collection: params.collectionSlug, ...location.query }}
+                        // use replace when changing sections so back button still takes you back to collections page
+                        onChangeSection={(section) => replace({
+                            ...location,
+                            query: { ...location.query, f: section }
+                        })}
+                        showCollectionName={false}
+                        editable={canEdit}
+                    />
+                </div>
+            </div>
+        );
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/EditLabels.css b/frontend/src/metabase/questions/containers/EditLabels.css
index 4803c38d1711a5be3067cea1f603f03527839e87..25e00fa35022b434612b23f31ecd58aecec54ff2 100644
--- a/frontend/src/metabase/questions/containers/EditLabels.css
+++ b/frontend/src/metabase/questions/containers/EditLabels.css
@@ -5,7 +5,7 @@
 }
 
 :local(.editor) {
-  composes: full from "style"
+  composes: flex-full from "style"
 }
 
 :local(.list) {
diff --git a/frontend/src/metabase/questions/containers/EditLabels.jsx b/frontend/src/metabase/questions/containers/EditLabels.jsx
index c1d37277f652f050ebd3f877e41a93c9295a3c24..45b3aaf0bce084634ae167659f656d9890b068a3 100644
--- a/frontend/src/metabase/questions/containers/EditLabels.jsx
+++ b/frontend/src/metabase/questions/containers/EditLabels.jsx
@@ -14,10 +14,10 @@ import * as colors from "metabase/lib/colors";
 
 const mapStateToProps = (state, props) => {
   return {
-      labels:           getLabels(state),
-      labelsLoading:    getLabelsLoading(state),
-      labelsError:      getLabelsError(state),
-      editingLabelId:   getEditingLabelId(state)
+      labels:           getLabels(state, props),
+      labelsLoading:    getLabelsLoading(state, props),
+      labelsError:      getLabelsError(state, props),
+      editingLabelId:   getEditingLabelId(state, props)
   }
 }
 
@@ -55,7 +55,11 @@ export default class EditLabels extends Component {
         return (
             <div className={S.editor} style={style}>
                 <div className="wrapper wrapper--trim">
-                    <div className={S.header}>Labels</div>
+                    <div className={S.header}>Add and edit labels</div>
+                    <div className="bordered border-error rounded p2 mb2">
+                        <h3 className="text-error mb1">Heads up!</h3>
+                        <div>In an upcoming release, Labels will be removed in favor of Collections.</div>
+                    </div>
                 </div>
                 <LabelEditorForm onSubmit={saveLabel} initialValues={{ icon: colors.normal.blue, name: "" }} submitButtonText={"Create Label"} className="wrapper wrapper--trim"/>
                 <LoadingAndErrorWrapper loading={labelsLoading} error={labelsError} noBackground noWrapper>
diff --git a/frontend/src/metabase/questions/containers/EntityBrowser.jsx b/frontend/src/metabase/questions/containers/EntityBrowser.jsx
deleted file mode 100644
index 2336fb6cb32a5fa0d1559e9c6e9b45c10a9f4394..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/questions/containers/EntityBrowser.jsx
+++ /dev/null
@@ -1,62 +0,0 @@
-/* eslint "react/prop-types": "warn" */
-import React, { Component, PropTypes } from "react";
-import ReactDOM from "react-dom";
-import { connect } from "react-redux";
-
-import Sidebar from "../components/Sidebar.jsx";
-import SidebarLayout from "metabase/components/SidebarLayout.jsx";
-
-import * as questionsActions from "../questions";
-import * as labelsActions from "../labels";
-import { getSections, getLabels, getLabelsLoading, getLabelsError } from "../selectors";
-
-const mapStateToProps = (state, props) => {
-  return {
-      sections:         getSections(state),
-      labels:           getLabels(state),
-      labelsLoading:    getLabelsLoading(state),
-      labelsError:      getLabelsError(state),
-  }
-}
-
-const mapDispatchToProps = {
-    ...questionsActions,
-    ...labelsActions
-};
-
-@connect(mapStateToProps, mapDispatchToProps)
-export default class EntityBrowser extends Component {
-    static propTypes = {
-        params:         PropTypes.object.isRequired,
-        selectSection:  PropTypes.func.isRequired,
-        loadLabels:     PropTypes.func.isRequired,
-        children:       PropTypes.any.isRequired,
-
-        sections:       PropTypes.array.isRequired,
-        labels:         PropTypes.array.isRequired,
-        labelsLoading:  PropTypes.bool.isRequired,
-        labelsError:    PropTypes.any,
-    };
-
-    componentWillMount() {
-        this.props.selectSection(this.props.params.section, this.props.params.slug);
-        this.props.loadLabels();
-    }
-
-    componentWillReceiveProps(newProps) {
-        if (this.props.params.section !== newProps.params.section || this.props.params.slug !== newProps.params.slug) {
-            this.props.selectSection(newProps.params.section, newProps.params.slug);
-        }
-    }
-
-    render() {
-        return (
-            <SidebarLayout
-                className="flex-full"
-                sidebar={<Sidebar {...this.props}/>}
-            >
-                {this.props.children}
-            </SidebarLayout>
-        );
-    }
-}
diff --git a/frontend/src/metabase/questions/containers/EntityItem.jsx b/frontend/src/metabase/questions/containers/EntityItem.jsx
index 66ff06013bf597fd6cfb57f67a6ee89fec33b892..42882525c8b39a4ed1e948b8e3ef6e9e22cf5c07 100644
--- a/frontend/src/metabase/questions/containers/EntityItem.jsx
+++ b/frontend/src/metabase/questions/containers/EntityItem.jsx
@@ -29,26 +29,26 @@ export default class EntityItem extends Component {
         item:               PropTypes.object.isRequired,
         setItemSelected:    PropTypes.func.isRequired,
         setFavorited:       PropTypes.func.isRequired,
-        setArchived:        PropTypes.func.isRequired
+        setArchived:        PropTypes.func.isRequired,
+        editable:           PropTypes.bool,
+        showCollectionName: PropTypes.bool,
+        onEntityClick:      PropTypes.func,
+        onMove:             PropTypes.func,
     };
 
     render() {
-        let { item, setItemSelected, setFavorited, setArchived } = this.props;
+        let { item, editable, setItemSelected, setFavorited, setArchived, onMove, onEntityClick, showCollectionName } = this.props;
         return (
             <li className="relative" style={{ display: item.visible ? undefined : "none" }}>
                 <Item
-                    id={item.id}
-                    name={item.name}
-                    created={item.created}
-                    by={item.by}
-                    favorite={item.favorite}
-                    archived={item.archived}
-                    icon={item.icon}
-                    selected={item.selected}
-                    labels={item.labels}
-                    setItemSelected={setItemSelected}
-                    setFavorited={setFavorited}
-                    setArchived={setArchived}
+                    setItemSelected={editable ? setItemSelected : null}
+                    setFavorited={editable ? setFavorited : null}
+                    setArchived={editable ? setArchived : null}
+                    onMove={editable ? onMove : null}
+                    onEntityClick={onEntityClick}
+                    showCollectionName={showCollectionName}
+                    entity={item}
+                    {...item}
                 />
             </li>
         )
diff --git a/frontend/src/metabase/questions/containers/EntityList.jsx b/frontend/src/metabase/questions/containers/EntityList.jsx
index 7942a1e9f2d15cab6ea4c0990b4faa4dbc77484b..20300476f8c5fc7a0773de86f99975a68e358272 100644
--- a/frontend/src/metabase/questions/containers/EntityList.jsx
+++ b/frontend/src/metabase/questions/containers/EntityList.jsx
@@ -3,42 +3,45 @@ import React, { Component, PropTypes } from "react";
 import ReactDOM from "react-dom";
 import { connect } from "react-redux";
 
+import Icon from "metabase/components/Icon";
+import EmptyState from "metabase/components/EmptyState";
+import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
+import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
+
 import S from "../components/List.css";
 
-import List from "../components/List.jsx";
-import SearchHeader from "../components/SearchHeader.jsx";
-import ActionHeader from "../components/ActionHeader.jsx";
-import EmptyState from "metabase/components/EmptyState.jsx";
-import UndoListing from "./UndoListing.jsx";
+import List from "../components/List";
+import SearchHeader from "../components/SearchHeader";
+import ActionHeader from "../components/ActionHeader";
 
-import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx";
+import _ from "underscore";
 
-import { setSearchText, setItemSelected, setAllSelected, setArchived } from "../questions";
+import { loadEntities, setSearchText, setItemSelected, setAllSelected, setArchived } from "../questions";
+import { loadLabels } from "../labels";
 import {
-    getSection, getEntityType, getEntityIds,
-    getSectionName, getSectionLoading, getSectionError,
+    getSection, getEntityIds,
+    getSectionLoading, getSectionError,
     getSearchText,
     getVisibleCount, getSelectedCount, getAllAreSelected, getSectionIsArchive,
     getLabelsWithSelectedState
 } from "../selectors";
 
+
 const mapStateToProps = (state, props) => {
   return {
-      sectionId:        getSection(state),
-      entityType:       getEntityType(state),
-      entityIds:        getEntityIds(state),
-      loading:          getSectionLoading(state),
-      error:            getSectionError(state),
+      section:          getSection(state, props),
+      entityIds:        getEntityIds(state, props),
+      loading:          getSectionLoading(state, props),
+      error:            getSectionError(state, props),
 
-      searchText:       getSearchText(state),
+      searchText:       getSearchText(state, props),
 
-      name:             getSectionName(state),
-      visibleCount:     getVisibleCount(state),
-      selectedCount:    getSelectedCount(state),
-      allAreSelected:   getAllAreSelected(state),
-      sectionIsArchive: getSectionIsArchive(state),
+      visibleCount:     getVisibleCount(state, props),
+      selectedCount:    getSelectedCount(state, props),
+      allAreSelected:   getAllAreSelected(state, props),
+      sectionIsArchive: getSectionIsArchive(state, props),
 
-      labels:           getLabelsWithSelectedState(state)
+      labels:           getLabelsWithSelectedState(state, props),
   }
 }
 
@@ -46,18 +49,66 @@ const mapDispatchToProps = {
     setItemSelected,
     setAllSelected,
     setSearchText,
-    setArchived
+    setArchived,
+    loadEntities,
+    loadLabels
+}
+
+const SECTIONS = [
+    {
+        section: 'all',
+        name: 'All questions',
+        icon: 'all',
+        empty: 'No questions have been saved yet.',
+    },
+    {
+        section: 'fav',
+        name: 'Favorites',
+        icon: 'star',
+        empty: 'You haven\'t favorited any questions yet.',
+    },
+    {
+        section: 'recent',
+        name: 'Recently viewed',
+        icon: 'recents',
+        empty: 'You haven\'t viewed any questions recently.',
+    },
+    {
+        section: 'mine',
+        name: 'Saved by me',
+        icon: 'mine',
+        empty:  'You haven\'t saved any questions yet.'
+    },
+    {
+        section: 'popular',
+        name: 'Most popular',
+        icon: 'popular',
+        empty: 'The most viewed questions across your company will show up here.',
+    },
+    {
+        section: 'archived',
+        name: "Archive",
+        icon: 'archive',
+        empty: 'If you no longer need a question, you can archive it.'
+    }
+];
+
+const DEFAULT_SECTION = {
+    icon: 'all',
+    empty: 'There aren\'t any questions matching that criteria.'
 }
 
 @connect(mapStateToProps, mapDispatchToProps)
 export default class EntityList extends Component {
     static propTypes = {
-        style:              PropTypes.object.isRequired,
-        sectionId:          PropTypes.string.isRequired,
-        name:               PropTypes.string.isRequired,
+        style:              PropTypes.object,
+
+        entityQuery:        PropTypes.object.isRequired,
+        entityType:         PropTypes.string.isRequired,
+
+        section:            PropTypes.string,
         loading:            PropTypes.bool.isRequired,
         error:              PropTypes.any,
-        entityType:         PropTypes.string.isRequired,
         entityIds:          PropTypes.array.isRequired,
         searchText:         PropTypes.string.isRequired,
         setSearchText:      PropTypes.func.isRequired,
@@ -68,105 +119,166 @@ export default class EntityList extends Component {
         labels:             PropTypes.array.isRequired,
         setItemSelected:    PropTypes.func.isRequired,
         setAllSelected:     PropTypes.func.isRequired,
-        setArchived:        PropTypes.func.isRequired
+        setArchived:        PropTypes.func.isRequired,
+
+        loadEntities:       PropTypes.func.isRequired,
+        loadLabels:         PropTypes.func.isRequired,
+
+        onEntityClick:      PropTypes.func,
+        onChangeSection:    PropTypes.func,
+        showSearchWidget:   PropTypes.bool.isRequired,
+        showCollectionName: PropTypes.bool.isRequired,
+        editable:           PropTypes.bool.isRequired,
+
+        defaultEmptyState:  PropTypes.string
     };
 
+    static defaultProps = {
+        showSearchWidget: true,
+        showCollectionName: true,
+        editable: true,
+    }
+
     componentDidUpdate(prevProps) {
         // Scroll to the top of the list if the section changed
         // A little hacky, something like https://github.com/taion/scroll-behavior might be better
-        if (this.props.sectionId !== prevProps.sectionId) {
+        if (this.props.section !== prevProps.section) {
             ReactDOM.findDOMNode(this).scrollTop = 0;
         }
     }
 
-    emptyState () {
-      switch (this.props.name) {
-        case 'All questions':
-          return {
-            icon: 'all',
-            message: 'No questions have been saved yet.'
-          }
-        case 'Recently viewed':
-          return {
-            icon: 'recents',
-            message: 'You haven\'t viewed any questions recently.'
-          }
-        case 'Saved by me':
-          return {
-            icon: 'mine',
-            message: 'You haven\'t saved any questions yet.'
-          }
-        case 'Favorites':
-          return {
-            icon: 'star',
-            message: 'You haven\'t favorited any questions yet.'
-          }
-        case 'Most popular':
-          return {
-            icon: 'popular' ,
-            message: 'The most viewed questions across your company will show up here.'
-          }
-        case 'Archive':
-          return {
-            icon: 'archive',
-            message: 'If you no longer need a question, you can archive it.'
-          }
-        default:
-          return {
-            icon: 'label',
-            message: 'There aren\'t any questions with this label.'
-          }
-      }
+    componentWillMount() {
+        this.props.loadLabels();
+        this.props.loadEntities(this.props.entityType, this.props.entityQuery);
+    }
+    componentWillReceiveProps(nextProps) {
+        if (!_.isEqual(this.props.entityQuery, nextProps.entityQuery) || nextProps.entityType !== this.props.entityType) {
+            this.props.loadEntities(nextProps.entityType, nextProps.entityQuery);
+        }
+    }
+
+    getSection () {
+        return _.findWhere(SECTIONS, { section: this.props.entityQuery && this.props.entityQuery.f || "all" }) || DEFAULT_SECTION;
     }
 
     render() {
         const {
             style,
-            name, loading, error,
+            loading, error,
             entityType, entityIds,
-            searchText, setSearchText,
+            searchText, setSearchText, showSearchWidget,
             visibleCount, selectedCount, allAreSelected, sectionIsArchive, labels,
-            setItemSelected, setAllSelected, setArchived
+            setItemSelected, setAllSelected, setArchived, onChangeSection,
+            showCollectionName,
+            editable, onEntityClick,
         } = this.props;
-        const empty = this.emptyState();
+
+        const section = this.getSection();
+
+        const showActionHeader = (editable && selectedCount > 0);
+        const showSearchHeader = (entityIds.length > 0 && showSearchWidget);
+        const showEntityFilterWidget = onChangeSection;
         return (
-            <div style={style} className="full">
-                  <div className="wrapper wrapper--trim">
-                    <div className={S.header}>
-                        {name}
-                    </div>
-                  </div>
-                  <LoadingAndErrorWrapper loading={!error && loading} error={error}>
-                  { () =>
-                        entityIds.length > 0 ? (
-                          <div className="wrapper wrapper--trim">
-                            <div className="flex align-center my1" style={{height: 40}}>
-                              { selectedCount > 0 ?
+            <div className="full" style={style}>
+                <div className="full">
+                    { (showActionHeader || showSearchHeader || showEntityFilterWidget) &&
+                        <div className="flex align-center my1" style={{height: 40}}>
+                            { showActionHeader ?
                                 <ActionHeader
-                                  visibleCount={visibleCount}
-                                  selectedCount={selectedCount}
-                                  allAreSelected={allAreSelected}
-                                  sectionIsArchive={sectionIsArchive}
-                                  setAllSelected={setAllSelected}
-                                  setArchived={setArchived}
-                                  labels={labels}
-                                  />
-                                :
-                                <SearchHeader searchText={searchText} setSearchText={setSearchText} />
-                              }
+                                    visibleCount={visibleCount}
+                                    selectedCount={selectedCount}
+                                    allAreSelected={allAreSelected}
+                                    sectionIsArchive={sectionIsArchive}
+                                    setAllSelected={setAllSelected}
+                                    setArchived={setArchived}
+                                    labels={labels}
+                                />
+                            : showSearchHeader ?
+                                <SearchHeader
+                                    searchText={searchText}
+                                    setSearchText={setSearchText}
+                                />
+                            :
+                                null
+                          }
+                          { showEntityFilterWidget && entityIds.length > 0 &&
+                              <EntityFilterWidget
+                                section={section}
+                                onChange={onChangeSection}
+                              />
+                          }
+                        </div>
+                    }
+                    <LoadingAndErrorWrapper className="full" loading={!error && loading} error={error}>
+                    { () =>
+                        entityIds.length > 0 ?
+                            <List
+                                entityType={entityType}
+                                entityIds={entityIds}
+                                editable={editable}
+                                setItemSelected={setItemSelected}
+                                onEntityClick={onEntityClick}
+                                showCollectionName={showCollectionName}
+                            />
+                        :
+                            <div className={S.empty}>
+                                <EmptyState message={section.section === "all" && this.props.defaultEmptyState ? this.props.defaultEmptyState : section.empty} icon={section.icon} />
                             </div>
-                            <List entityType={entityType} entityIds={entityIds} setItemSelected={setItemSelected} />
-                          </div>
-                        ) : (
-                          <div className={S.empty}>
-                            <EmptyState message={empty.message} icon={empty.icon} />
-                          </div>
-                        )
-                  }
-                  </LoadingAndErrorWrapper>
-                <UndoListing />
-
+                    }
+                    </LoadingAndErrorWrapper>
+                </div>
             </div>
         );
     }
 }
+
+class EntityFilterWidget extends Component {
+    static propTypes = {
+        section: PropTypes.object.isRequired,
+        onChange: PropTypes.func.isRequired,
+    }
+    render() {
+        const { section, onChange } = this.props;
+        return (
+            <PopoverWithTrigger
+                ref={p => this.popover = p}
+                triggerClasses="block ml-auto flex-no-shrink"
+                targetOffsetY={10}
+                triggerElement={
+                    <div className="ml2 flex align-center text-brand">
+                        <span className="text-bold">{section && section.name}</span>
+                        <Icon
+                            ref={i => this.icon = i}
+                            className="ml1"
+                            name="chevrondown"
+                            width="12"
+                            height="12"
+                        />
+                    </div>
+                }
+                target={() => this.icon}
+            >
+                <ol className="text-brand mt2 mb1">
+                    { SECTIONS.filter(item => item.section !== "archived").map((item, index) =>
+                        <li
+                            key={index}
+                            className="cursor-pointer flex align-center brand-hover px2 py1 mb1"
+                            onClick={() => {
+                                onChange(item.section);
+                                this.popover.close();
+                            }}
+                        >
+                            <Icon
+                                className="mr1 text-light-blue"
+                                name={item.icon}
+                            />
+                            <h4 className="List-item-title">
+                                {item.name}
+                            </h4>
+                        </li>
+                    ) }
+                </ol>
+            </PopoverWithTrigger>
+        )
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/LabelPopover.jsx b/frontend/src/metabase/questions/containers/LabelPopover.jsx
index 78d34c105044de9ad24d570b719d4fe637ba9d10..b1c67461ba9e8c6c31141daab2f15298ffd3b019 100644
--- a/frontend/src/metabase/questions/containers/LabelPopover.jsx
+++ b/frontend/src/metabase/questions/containers/LabelPopover.jsx
@@ -10,7 +10,7 @@ import { getLabels } from "../selectors";
 
 const mapStateToProps = (state, props) => {
   return {
-      labels: props.labels || getLabels(state)
+      labels: props.labels || getLabels(state, props)
   }
 }
 
diff --git a/frontend/src/metabase/questions/containers/MoveToCollection.jsx b/frontend/src/metabase/questions/containers/MoveToCollection.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..eb4d181e53befba1191a72217455abae322483f7
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/MoveToCollection.jsx
@@ -0,0 +1,94 @@
+import React, { Component } from "react";
+import { connect } from "react-redux";
+
+import Button from "metabase/components/Button";
+import Icon from "metabase/components/Icon";
+import ModalContent from "metabase/components/ModalContent";
+
+import CollectionList from "./CollectionList";
+
+import cx from "classnames";
+
+import { setCollection } from "../questions";
+import { loadCollections } from "../collections";
+
+const mapStateToProps = (state, props) => ({
+
+})
+
+const mapDispatchToProps = {
+    loadCollections,
+    setCollection
+}
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class MoveToCollection extends Component {
+    constructor(props) {
+        super(props);
+        this.state = {
+            collectionId: props.initialCollectionId
+        }
+    }
+
+    componentWillMount() {
+        this.props.loadCollections()
+    }
+
+    async onMove(collectionId) {
+        try {
+            this.setState({ error: null })
+            await this.props.setCollection(this.props.questionId, collectionId, true);
+            this.props.onClose();
+        } catch (e) {
+            this.setState({ error: e })
+        }
+    }
+
+    render() {
+        const { onClose } = this.props;
+        const { collectionId, error } = this.state;
+        return (
+            <ModalContent
+                title="Which collection should this be in?"
+                footer={
+                    <div>
+                        { error &&
+                            <span className="text-error mr1">{error.data && error.data.message}</span>
+                        }
+                        <Button className="mr1" onClick={onClose}>
+                            Cancel
+                        </Button>
+                        <Button primary disabled={collectionId === undefined} onClick={() => this.onMove(collectionId)}>
+                            Move
+                        </Button>
+                    </div>
+                }
+                onClose={onClose}
+            >
+                <CollectionList writable>
+                    { collections =>
+                        <ol className="List text-brand ml-auto mr-auto" style={{ width: 520 }}>
+                            { [{ name: "None", id: null }].concat(collections).map((collection, index) =>
+                                <li
+                                    className={cx("List-item flex align-center cursor-pointer mb1 p1", { "List-item--selected": collection.id === collectionId })}
+                                    key={index}
+                                    onClick={() => this.setState({ collectionId: collection.id })}
+                                >
+                                    <Icon
+                                        className="Icon mr2"
+                                        name="all"
+                                        style={{
+                                            color: collection.color,
+                                            visibility: collection.color == null ? "hidden" : null
+                                        }}
+                                    />
+                                    <h3 className="List-item-title">{collection.name}</h3>
+                                </li>
+                            )}
+                        </ol>
+                    }
+                </CollectionList>
+            </ModalContent>
+        )
+    }
+}
diff --git a/frontend/src/metabase/questions/containers/QuestionIndex.jsx b/frontend/src/metabase/questions/containers/QuestionIndex.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..45d3076ffcefb0dfc19b4c27bb59e23d2ed60667
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/QuestionIndex.jsx
@@ -0,0 +1,127 @@
+import React, { Component, PropTypes } from "react";
+import { connect } from "react-redux";
+import { Link } from "react-router";
+import Collapse from "react-collapse";
+
+import Icon from "metabase/components/Icon";
+import Button from "metabase/components/Button";
+import DisclosureTriangle from "metabase/components/DisclosureTriangle";
+import TitleAndDescription from "metabase/components/TitleAndDescription";
+import ExpandingSearchField from "../components/ExpandingSearchField";
+import CollectionActions from "../components/CollectionActions";
+
+import CollectionButtons from "../components/CollectionButtons"
+
+import EntityList from "./EntityList";
+
+import { search } from "../questions";
+import { loadCollections } from "../collections";
+import { getAllCollections, getAllEntities } from "../selectors";
+import { getUserIsAdmin } from "metabase/selectors/user";
+
+import { replace, push } from "react-router-redux";
+
+const mapStateToProps = (state, props) => ({
+    questions:   getAllEntities(state, props),
+    collections: getAllCollections(state, props),
+    isAdmin:     getUserIsAdmin(state, props),
+})
+
+const mapDispatchToProps = ({
+    search,
+    loadCollections,
+    replace,
+    push,
+})
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class QuestionIndex extends Component {
+    constructor (props) {
+        super(props);
+        this.state = {
+            questionsExpanded: true
+        }
+    }
+    componentWillMount () {
+        this.props.loadCollections();
+    }
+
+    render () {
+        const { questions, collections, replace, push, location, isAdmin } = this.props;
+        const { questionsExpanded } = this.state;
+        const hasCollections = collections && collections.length > 0;
+        const hasQuestions = questions && questions.length > 0;
+        const showCollections = isAdmin || hasCollections;
+        const showQuestions = hasQuestions || !showCollections || location.query.f != null;
+        return (
+            <div className="relative mx4">
+                <div className="flex align-center pt4 pb2">
+                    <TitleAndDescription title={ showCollections ? "Collections of Questions" : "Saved Questions" } />
+                    <div className="flex align-center ml-auto">
+                        <ExpandingSearchField className="mr2" onSearch={this.props.search} />
+
+                        <CollectionActions>
+                            { isAdmin && hasCollections &&
+                                <Link to="/collections/permissions">
+                                    <Icon name="lock" tooltip="Set permissions for collections" />
+                                </Link>
+                            }
+                            <Link to="/questions/archive">
+                                <Icon name="viewArchive" tooltip="View the archive" />
+                            </Link>
+                        </CollectionActions>
+                    </div>
+                </div>
+                { showCollections &&
+                    <div>
+                        { collections.length > 0 ?
+                            <CollectionButtons collections={collections} isAdmin={isAdmin} push={push} />
+                        :
+                            <CollectionEmptyState />
+                        }
+                    </div>
+                }
+                {/* only show title if we're showing the questions AND collections, otherwise title goes in the main header */}
+                { showQuestions && showCollections &&
+                    <div
+                        className="inline-block mt2 mb2 cursor-pointer text-brand-hover"
+                        onClick={() => this.setState({ questionsExpanded: !questionsExpanded })}
+                    >
+                        <div className="flex align-center">
+                            <DisclosureTriangle open={questionsExpanded} />
+                            <h2>Everything Else</h2>
+                        </div>
+                    </div>
+                }
+                <Collapse isOpened={showQuestions && (questionsExpanded || !showCollections)} keepCollapsedContent={true}>
+                    <EntityList
+                        entityType="cards"
+                        entityQuery={{ f: "all", collection: "", ...location.query }}
+                        // use replace when changing sections so back button still takes you back to collections page
+                        onChangeSection={(section) => replace({
+                            ...location,
+                            query: { ...location.query, f: section }
+                        })}
+                        defaultEmptyState="Questions that aren’t in a collection will be shown here"
+                    />
+                </Collapse>
+            </div>
+        )
+    }
+}
+
+const CollectionEmptyState = () =>
+    <div className="flex align-center p2 bordered border-med border-brand rounded bg-grey-0 text-brand">
+        <Icon name="collection" size={32} className="mr2"/>
+        <div className="flex-full">
+            <h3>Create collections for your saved questions</h3>
+            <div className="mt1">
+                Collections help you organize your questions and allow you to decide who gets to see what.
+                {" "}
+                <Link to="http://metabase.com/FIXME">Learn more</Link>
+            </div>
+        </div>
+        <Link to="/collections/create">
+            <Button primary>Create a collection</Button>
+        </Link>
+    </div>
diff --git a/frontend/src/metabase/questions/containers/SearchResults.jsx b/frontend/src/metabase/questions/containers/SearchResults.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f6bc31e77c4779caf815be9248a964d498979168
--- /dev/null
+++ b/frontend/src/metabase/questions/containers/SearchResults.jsx
@@ -0,0 +1,53 @@
+import React, { Component } from "react";
+import { connect } from "react-redux";
+
+import HeaderWithBack from "metabase/components/HeaderWithBack";
+
+import ExpandingSearchField from "../components/ExpandingSearchField";
+import EntityList from "./EntityList";
+
+import { inflect } from "metabase/lib/formatting";
+
+import { getTotalCount } from "../selectors";
+import { search } from "../questions";
+
+const mapStateToProps = (state, props) => ({
+    totalCount: getTotalCount(state, props),
+})
+
+const mapDispatchToProps = ({
+    // pass "true" as 2nd arg to replace history state so back button still takes you back to index
+    search: (term) => search(term, true)
+})
+
+@connect(mapStateToProps, mapDispatchToProps)
+class SearchResults extends Component {
+    render () {
+        const { totalCount } = this.props;
+        return (
+            <div className="px4 pt3">
+                <div className="flex align-center mb3">
+                    <HeaderWithBack name={totalCount != null ?
+                        `${totalCount} ${inflect("result", totalCount)}` :
+                        "Search results"}
+                    />
+                    <div className="ml-auto flex align-center">
+                        <ExpandingSearchField
+                            active
+                            defaultValue={this.props.location.query.q}
+                            onSearch={this.props.search}
+                        />
+                    </div>
+                </div>
+                <EntityList
+                    entityType="cards"
+                    entityQuery={this.props.location.query}
+                    showSearchWidget={false}
+                    defaultEmptyState="No matching questions found"
+                />
+            </div>
+        );
+    }
+}
+
+export default SearchResults;
diff --git a/frontend/src/metabase/questions/questions.js b/frontend/src/metabase/questions/questions.js
index 2933c20c2ac5f74832e878541502c87fe57d8bf0..d661a30f1cd2eb3a9d071745c705afabcf0e1ce8 100644
--- a/frontend/src/metabase/questions/questions.js
+++ b/frontend/src/metabase/questions/questions.js
@@ -1,73 +1,64 @@
 
-import { createAction, createThunkAction } from "metabase/lib/redux";
+import { createAction, createThunkAction, momentifyArraysTimestamps } from "metabase/lib/redux";
 
 import { normalize, Schema, arrayOf } from 'normalizr';
-import i from "icepick";
+import { getIn, assoc, assocIn, updateIn, merge, chain } from "icepick";
 import _ from "underscore";
 
 import { inflect } from "metabase/lib/formatting";
 import MetabaseAnalytics from "metabase/lib/analytics";
+import Urls from "metabase/lib/urls";
+
+import { push, replace } from "react-router-redux";
 import { setRequestState } from "metabase/redux/requests";
+import { addUndo } from "metabase/redux/undo";
 
 import { getVisibleEntities, getSelectedEntities } from "./selectors";
-import { addUndo } from "./undo";
+
+import { SET_COLLECTION_ARCHIVED } from "./collections";
 
 const card = new Schema('cards');
 const label = new Schema('labels');
+const collection = new Schema('collections');
 card.define({
-  labels: arrayOf(label)
+  labels: arrayOf(label),
+  // collection: collection
 });
 
-import { CardApi } from "metabase/services";
+import { CardApi, CollectionsApi } from "metabase/services";
 
-const SELECT_SECTION = 'metabase/questions/SELECT_SECTION';
+const LOAD_ENTITIES = 'metabase/questions/LOAD_ENTITIES';
 const SET_SEARCH_TEXT = 'metabase/questions/SET_SEARCH_TEXT';
 const SET_ITEM_SELECTED = 'metabase/questions/SET_ITEM_SELECTED';
 const SET_ALL_SELECTED = 'metabase/questions/SET_ALL_SELECTED';
 const SET_FAVORITED = 'metabase/questions/SET_FAVORITED';
 const SET_ARCHIVED = 'metabase/questions/SET_ARCHIVED';
 const SET_LABELED = 'metabase/questions/SET_LABELED';
+const SET_COLLECTION = 'metabase/collections/SET_COLLECTION';
 
-export const selectSection = createThunkAction(SELECT_SECTION, (section = "all", slug = null, type = "cards") => {
+export const loadEntities = createThunkAction(LOAD_ENTITIES, (entityType, entityQueryObject) => {
     return async (dispatch, getState) => {
-        let response;
-        switch (section) {
-            case "all":
-                dispatch(setRequestState({ statePath: ['questions', 'fetch'], state: "LOADING" }));
-                response = await CardApi.list({ f: "all" });
-                dispatch(setRequestState({ statePath: ['questions', 'fetch'], state: "LOADED" }));
-                break;
-            case "favorites":
-                response = await CardApi.list({ f: "fav" });
-                break;
-            case "saved":
-                response = await CardApi.list({ f: "mine" });
-                break;
-            case "popular":
-                response = await CardApi.list({ f: "popular" });
-                break;
-            case "recent":
-                response = await CardApi.list({ f: "recent" });
-                break;
-            case "archived":
-                response = await CardApi.list({ f: "archived" });
-                break;
-            case "label":
-                response = await CardApi.list({ label: slug });
-                break;
-            default:
-                console.warn("unknown section " + section);
-                response = [];
-        }
-
-        if (slug) {
-            section += "-" + slug;
+        let entityQuery = JSON.stringify(entityQueryObject);
+        try {
+            let result;
+            dispatch(setRequestState({ statePath: ['questions', 'fetch'], state: "LOADING" }));
+            if (entityType === "cards") {
+                result = { entityType, entityQuery, ...normalize(momentifyArraysTimestamps(await CardApi.list(entityQueryObject)), arrayOf(card)) };
+            } else if (entityType === "collections") {
+                result = { entityType, entityQuery, ...normalize(momentifyArraysTimestamps(await CollectionsApi.list(entityQueryObject)), arrayOf(collection)) };
+            } else {
+                throw "Unknown entity type " + entityType;
+            }
+            dispatch(setRequestState({ statePath: ['questions', 'fetch'], state: "LOADED" }));
+            return result;
+        } catch (error) {
+            throw { entityType, entityQuery, error };
         }
-
-        return { type, section, ...normalize(response, arrayOf(card)) };
     }
 });
 
+export const search = (q, repl) => (repl ? replace : push)("/questions/search?q=" + encodeURIComponent(q))
+
 export const setFavorited = createThunkAction(SET_FAVORITED, (cardId, favorited) => {
     return async (dispatch, getState) => {
         if (favorited) {
@@ -80,11 +71,22 @@ export const setFavorited = createThunkAction(SET_FAVORITED, (cardId, favorited)
     }
 });
 
-function createUndo(type, actions) {
+import React from "react";
+import { Link } from "react-router";
+
+function createUndo(type, actions, collection) {
     return {
         type: type,
         count: actions.length,
-        message: (undo) => undo.count + " " + inflect(null, undo.count, "question was", "questions were") + " " + type,
+        message: (undo) => // eslint-disable-line react/display-name
+            <div className="flex flex-column">
+                <div>
+                    { undo.count + " " + inflect(null, undo.count, "question was", "questions were") + " " + type }
+                    { undo.count === 1 && collection &&
+                        <span> to the <Link className="link" to={Urls.collection(collection)}>{collection.name}</Link> collection.</span>
+                    }
+                </div>
+            </div>,
         actions: actions
     };
 }
@@ -112,7 +114,8 @@ export const setArchived = createThunkAction(SET_ARCHIVED, (cardId, archived, un
             if (undoable) {
                 dispatch(addUndo(createUndo(
                     archived ? "archived" : "unarchived",
-                    [setArchived(cardId, !archived)]
+                    [setArchived(cardId, !archived)],
+                    !archived && card.collection
                 )));
                 MetabaseAnalytics.trackEvent("Questions", archived ? "Archive" : "Unarchive");
             }
@@ -137,8 +140,8 @@ export const setLabeled = createThunkAction(SET_LABELED, (cardId, labelId, label
             }
         } else {
             const state = getState();
-            const labelSlug = i.getIn(state.questions, ["entities", "labels", labelId, "slug"]);
-            const labels = i.getIn(state.questions, ["entities", "cards", cardId, "labels"]);
+            const labelSlug = getIn(state.questions, ["entities", "labels", labelId, "slug"]);
+            const labels = getIn(state.questions, ["entities", "cards", cardId, "labels"]);
             const newLabels = labels.filter(id => id !== labelId);
             if (labeled) {
                 newLabels.push(labelId);
@@ -158,6 +161,40 @@ export const setLabeled = createThunkAction(SET_LABELED, (cardId, labelId, label
     }
 });
 
+const getCardCollectionId = (state, cardId) => getIn(state, ["questions", "entities", "cards", cardId, "collection_id"])
+
+export const setCollection = createThunkAction(SET_COLLECTION, (cardId, collectionId, undoable = false) => {
+    return async (dispatch, getState) => {
+        const state = getState();
+        if (cardId == null) {
+            // bulk move
+            let selected = getSelectedEntities(getState());
+            if (undoable) {
+                dispatch(addUndo(createUndo(
+                    "moved",
+                    selected.map(item => setCollection(item.id, getCardCollectionId(state, item.id)))
+                )));
+                MetabaseAnalytics.trackEvent("Questions", "Bulk Move to Collection");
+            }
+            selected.map(item => dispatch(setCollection(item.id, collectionId)));
+        } else {
+            const collection = _.findWhere(state.collections.collections, { id: collectionId });
+            if (undoable) {
+                dispatch(addUndo(createUndo(
+                    "moved",
+                    [setCollection(cardId, getCardCollectionId(state, cardId))]
+                )));
+                MetabaseAnalytics.trackEvent("Questions", "Move to Collection");
+            }
+            const card = await CardApi.update({ id: cardId, collection_id: collectionId });
+            return {
+                ...card,
+                _changedSectionSlug: collection && collection.slug
+            }
+        }
+    }
+});
+
 export const setSearchText = createAction(SET_SEARCH_TEXT);
 export const setItemSelected = createAction(SET_ITEM_SELECTED);
 
@@ -176,9 +213,9 @@ export const setAllSelected = createThunkAction(SET_ALL_SELECTED, (selected) =>
 });
 
 const initialState = {
+    lastEntityType: null,
+    lastEntityQuery: null,
     entities: {},
-    type: "cards",
-    section: null,
     itemsBySection: {},
     searchText: "",
     selectedIds: {},
@@ -187,7 +224,7 @@ const initialState = {
 
 export default function(state = initialState, { type, payload, error }) {
     if (payload && payload.entities) {
-        state = i.assoc(state, "entities", i.merge(state.entities, payload.entities));
+        state = assoc(state, "entities", merge(state.entities, payload.entities));
     }
 
     switch (type) {
@@ -197,69 +234,80 @@ export default function(state = initialState, { type, payload, error }) {
             return { ...state, selectedIds: { ...state.selectedIds, ...payload } };
         case SET_ALL_SELECTED:
             return { ...state, selectedIds: payload };
-        case SELECT_SECTION:
+        case LOAD_ENTITIES:
             if (error) {
-                return i.assoc(state, "sectionError", payload);
+                return assocIn(state, ["itemsBySection", payload.entityType, payload.entityQuery, "error"], payload.error);
             } else {
-                return (i.chain(state)
-                    .assoc("type", payload.type)
-                    .assoc("section", payload.section)
-                    .assoc("sectionError", null)
+                return (chain(state)
+                    .assoc("lastEntityType", payload.entityType)
+                    .assoc("lastEntityQuery", payload.entityQuery)
                     .assoc("selectedIds", {})
                     .assoc("searchText", "")
-                    .assocIn(["itemsBySection", payload.type, payload.section, "items"], payload.result)
+                    .assocIn(["itemsBySection", payload.entityType, payload.entityQuery, "error"], null)
+                    .assocIn(["itemsBySection", payload.entityType, payload.entityQuery, "items"], payload.result)
                     // store the initial sort order so if we remove and undo an item it can be put back in it's original location
-                    .assocIn(["itemsBySection", payload.type, payload.section, "sortIndex"], payload.result.reduce((o, id, i) => { o[id] = i; return o; }, {}))
+                    .assocIn(["itemsBySection", payload.entityType, payload.entityQuery, "sortIndex"], payload.result.reduce((o, id, i) => { o[id] = i; return o; }, {}))
                     .value());
             }
         case SET_FAVORITED:
             if (error) {
                 return state;
             } else if (payload && payload.id != null) {
-                state = i.assocIn(state, ["entities", "cards", payload.id], {
-                    ...state.entities.cards[payload.id],
+                state = assocIn(state, ["entities", "cards", payload.id], {
+                    ...getIn(state, ["entities", "cards", payload.id]),
                     ...payload
                 });
-                if (payload.favorite) {
-                    state = addToSection(state, "cards", "favorites", payload.id);
-                } else {
-                    state = removeFromSection(state, "cards", "favorites", payload.id);
-                }
-                return state;
+                // FIXME: incorrectly adds to sections it may not have previously been in, but not a big deal since we reload whens switching sections
+                state = updateSections(state, "cards", payload.id, (s) => s.f === "fav", payload.favorite);
             }
             return state;
         case SET_ARCHIVED:
             if (error) {
                 return state;
             } else if (payload && payload.id != null) {
-                state = i.assocIn(state, ["entities", "cards", payload.id], {
-                    ...state.entities.cards[payload.id],
+                state = assocIn(state, ["entities", "cards", payload.id], {
+                    ...getIn(state, ["entities", "cards", payload.id]),
                     ...payload
                 });
-                for (let section in state.itemsBySection.cards) {
-                    if (payload.archived ? section === "archived" : section !== "archived") {
-                        state = addToSection(state, "cards", section, payload.id);
-                    } else {
-                        state = removeFromSection(state, "cards", section, payload.id);
-                    }
-                }
-                return state;
+                // FIXME: incorrectly adds to sections it may not have previously been in, but not a big deal since we reload whens switching sections
+                state = updateSections(state, "cards", payload.id, (s) => s.f === "archived", payload.archived);
+                state = updateSections(state, "cards", payload.id, (s) => s.f !== "archived", !payload.archived);
             }
             return state;
         case SET_LABELED:
             if (error) {
                 return state;
             } else if (payload && payload.id != null) {
-                state = i.assocIn(state, ["entities", "cards", payload.id], {
-                    ...state.entities.cards[payload.id],
+                state = assocIn(state, ["entities", "cards", payload.id], {
+                    ...getIn(state, ["entities", "cards", payload.id]),
                     ...payload
                 });
-                if (payload._changedLabeled) {
-                    state = addToSection(state, "cards", "label-" + payload._changedLabelSlug, payload.id);
-                } else {
-                    state = removeFromSection(state, "cards", "label-" + payload._changedLabelSlug, payload.id);
-                }
+                // FIXME: incorrectly adds to sections it may not have previously been in, but not a big deal since we reload whens switching sections
+                state = updateSections(state, "cards", payload.id, (s) => s.label === payload._changedLabelSlug, payload._changedLabeled);
+            }
+            return state;
+        case SET_COLLECTION:
+            if (error) {
+                return state;
+            } else if (payload && payload.id != null) {
+                state = assocIn(state, ["entities", "cards", payload.id], {
+                    ...getIn(state, ["entities", "cards", payload.id]),
+                    ...payload
+                });
+                state = updateSections(state, "cards", payload.id, (s) => s.collection !== payload._changedSectionSlug, false);
+                state = updateSections(state, "cards", payload.id, (s) => s.collection === payload._changedSectionSlug, true);
+            }
+            return state;
+        case SET_COLLECTION_ARCHIVED:
+            if (error) {
                 return state;
+            } else if (payload && payload.id != null) {
+                state = assocIn(state, ["entities", "collections", payload.id], {
+                    ...getIn(state, ["entities", "collections", payload.id]),
+                    ...payload
+                });
+                state = updateSections(state, "collections", payload.id, (s) => s.archived, payload.archived);
+                state = updateSections(state, "collections", payload.id, (s) => !s.archived, !payload.archived);
             }
             return state;
         default:
@@ -267,18 +315,18 @@ export default function(state = initialState, { type, payload, error }) {
     }
 }
 
-function addToSection(state, type, section, id) {
-    let items = i.getIn(state, ["itemsBySection", type, section, "items"]);
-    if (items && !_.contains(items, id)) {
-        return i.setIn(state, ["itemsBySection", type, section, "items"], items.concat(id));
-    }
-    return state;
-}
-
-function removeFromSection(state, type, section, id) {
-    let items = i.getIn(state, ["itemsBySection", type, section, "items"]);
-    if (items && _.contains(items, id)) {
-        return i.setIn(state, ["itemsBySection", type, section, "items"], items.filter(i => i !== id));
-    }
-    return state;
+function updateSections(state, entityType, entityId, sectionPredicate, shouldContain) {
+    return updateIn(state, ["itemsBySection", entityType], (entityQueries) =>
+        _.mapObject(entityQueries, (entityQueryResult, entityQuery) => {
+            if (sectionPredicate(JSON.parse(entityQuery))) {
+                const doesContain = _.contains(entityQueryResult.items, entityId);
+                if (!doesContain && shouldContain) {
+                    return { ...entityQueryResult, items: entityQueryResult.items.concat(entityId) };
+                } else if (doesContain && !shouldContain) {
+                    return { ...entityQueryResult, items: entityQueryResult.items.filter(id => id !== entityId) };
+                }
+            }
+            return entityQueryResult;
+        })
+    );
 }
diff --git a/frontend/src/metabase/questions/selectors.js b/frontend/src/metabase/questions/selectors.js
index b5c35503af769fb8f4fa1453b140eceeb3afc3b8..8710ce228e17af25be5bace0ad6f6300c95f2e33 100644
--- a/frontend/src/metabase/questions/selectors.js
+++ b/frontend/src/metabase/questions/selectors.js
@@ -1,7 +1,7 @@
 
 import { createSelector } from 'reselect';
 import moment from "moment";
-import i from "icepick";
+import { getIn } from "icepick";
 import _ from "underscore";
 
 import visualizations from "metabase/visualizations";
@@ -10,18 +10,32 @@ function caseInsensitiveSearch(haystack, needle) {
     return !needle || (haystack != null && haystack.toLowerCase().indexOf(needle.toLowerCase()) >= 0);
 }
 
-export const getEntityType          = (state) => state.questions.type
-export const getSection             = (state) => state.questions.section
-export const getEntities            = (state) => state.questions.entities
-export const getItemsBySection      = (state) => state.questions.itemsBySection
+export const getEntityType          = (state, props) => props && props.entityType ? props.entityType : state.questions.lastEntityType;
+export const getEntityQuery         = (state, props) => props && props.entityQuery ? JSON.stringify(props.entityQuery) : state.questions.lastEntityQuery;
 
-export const getSearchText          = (state) => state.questions.searchText;
-export const getSelectedIds         = (state) => state.questions.selectedIds;
+export const getSection             = (state, props) => props.entityQuery && JSON.stringify(props.entityQuery);
+export const getEntities            = (state, props) => state.questions.entities
+export const getItemsBySection      = (state, props) => state.questions.itemsBySection
+
+export const getSearchText          = (state, props) => state.questions.searchText;
+export const getSelectedIds         = (state, props) => state.questions.selectedIds;
+
+export const getAllCollections      = (state, props) => state.collections.collections;
+
+export const getWritableCollections = createSelector(
+    [getAllCollections],
+    (collections) => _.filter(collections, collection => collection.can_write)
+);
+
+export const getQuery = createSelector(
+    [getEntityQuery],
+    (entityQuery) => entityQuery && JSON.parse(entityQuery)
+);
 
 const getSectionData = createSelector(
-    [getItemsBySection, getEntityType, getSection],
-    (itemsBySection, type, section) =>
-        i.getIn(itemsBySection, [type, section])
+    [getItemsBySection, getEntityType, getEntityQuery],
+    (itemsBySection, entityType, entityQuery) =>
+        getIn(itemsBySection, [entityType, entityQuery])
 );
 
 export const getSectionLoading = createSelector(
@@ -30,8 +44,11 @@ export const getSectionLoading = createSelector(
         !(sectionData && sectionData.items)
 );
 
-export const getSectionError = (state) =>
-    !!state.questions.sectionError;
+export const getSectionError = createSelector(
+    [getSectionData],
+    (sectionData) =>
+        (sectionData && sectionData.error)
+);
 
 export const getEntityIds = createSelector(
     [getSectionData],
@@ -40,15 +57,15 @@ export const getEntityIds = createSelector(
 );
 
 const getEntity = (state, props) =>
-    getEntities(state)[props.entityType][props.entityId];
+    getEntities(state, props)[props.entityType][props.entityId];
 
 const getEntitySelected = (state, props) =>
-    getSelectedIds(state)[props.entityId] || false;
+    getSelectedIds(state, props)[props.entityId] || false;
 
 const getEntityVisible = (state, props) =>
-    caseInsensitiveSearch(getEntity(state, props).name, getSearchText(state));
+    caseInsensitiveSearch(getEntity(state, props).name, getSearchText(state, props));
 
-const getLabelEntities = (state) => state.labels.entities.labels
+const getLabelEntities = (state, props) => state.labels.entities.labels
 
 export const makeGetItem = () => {
     const getItem = createSelector(
@@ -56,23 +73,25 @@ export const makeGetItem = () => {
         (entity, selected, visible, labelEntities) => ({
             name: entity.name,
             id: entity.id,
-            created: moment(entity.created_at).fromNow(),
-            by: entity.creator.common_name,
+            created: entity.created_at ? moment(entity.created_at).fromNow() : null,
+            by: entity.creator && entity.creator.common_name,
             icon: visualizations.get(entity.display).iconName,
             favorite: entity.favorite,
             archived: entity.archived,
-            labels: entity.labels.map(labelId => labelEntities[labelId]).filter(l => l),
+            collection: entity.collection,
+            labels: entity.labels ? entity.labels.map(labelId => labelEntities[labelId]).filter(l => l) : [],
             selected,
-            visible
+            visible,
+            description: entity.description
         })
     );
     return getItem;
 }
 
-const getAllEntities = createSelector(
+export const getAllEntities = createSelector(
     [getEntityIds, getEntityType, getEntities],
     (entityIds, entityType, entities) =>
-        entityIds.map(entityId => entities[entityType][entityId])
+        entityIds.map(entityId => getIn(entities, [entityType, entityId]))
 );
 
 export const getVisibleEntities = createSelector(
@@ -87,6 +106,11 @@ export const getSelectedEntities = createSelector(
         visibleEntities.filter(entity => selectedIds[entity.id])
 );
 
+export const getTotalCount = createSelector(
+    [getAllEntities],
+    (entities) => entities.length
+);
+
 export const getVisibleCount = createSelector(
     [getVisibleEntities],
     (visibleEntities) => visibleEntities.length
@@ -104,31 +128,31 @@ export const getAllAreSelected = createSelector(
 );
 
 export const getSectionIsArchive = createSelector(
-    [getSection],
-    (section) =>
-        section === "archived"
+    [getQuery],
+    (query) =>
+        query && query.f === "archived"
 );
 
 const sections = [
     { id: "all",       name: "All questions",   icon: "all" },
-    { id: "favorites", name: "Favorites",       icon: "star" },
+    { id: "fav",       name: "Favorites",       icon: "star" },
     { id: "recent",    name: "Recently viewed", icon: "recents" },
-    { id: "saved",     name: "Saved by me",     icon: "mine" },
+    { id: "mine",      name: "Saved by me",     icon: "mine" },
     { id: "popular",   name: "Most popular",    icon: "popular" }
 ];
 
-export const getSections    = (state) => sections;
+export const getSections    = (state, props) => sections;
 
-export const getEditingLabelId = (state) => state.labels.editing;
+export const getEditingLabelId = (state, props) => state.labels.editing;
 
 export const getLabels = createSelector(
-    [(state) => state.labels.entities.labels, (state) => state.labels.labelIds],
+    [(state, props) => state.labels.entities.labels, (state, props) => state.labels.labelIds],
     (labelEntities, labelIds) =>
         labelIds ? labelIds.map(id => labelEntities[id]).sort((a, b) => a.name.localeCompare(b.name)) : []
 );
 
-export const getLabelsLoading = (state) => !state.labels.labelIds;
-export const getLabelsError = (state) => state.labels.error;
+export const getLabelsLoading = (state, props) => !state.labels.labelIds;
+export const getLabelsError = (state, props) => state.labels.error;
 
 const getLabelCountsForSelectedEntities = createSelector(
     [getSelectedEntities],
@@ -178,5 +202,3 @@ export const getSectionName = createSelector(
         return "";
     }
 );
-
-export const getUndos = (state) => state.undo;
diff --git a/frontend/src/metabase/reducers.js b/frontend/src/metabase/reducers.js
index dae71f013348b0a4fac9e7a5fa4ad42c21927fad..0cd9ca0a5ee93f9e3aa771e70cfe9829de349406 100644
--- a/frontend/src/metabase/reducers.js
+++ b/frontend/src/metabase/reducers.js
@@ -6,6 +6,7 @@ import auth from "metabase/auth/auth";
 /* ducks */
 import metadata from "metabase/redux/metadata";
 import requests from "metabase/redux/requests";
+import undo from "metabase/redux/undo";
 
 /* admin */
 import settings from "metabase/admin/settings/settings";
@@ -21,7 +22,7 @@ import * as home from "metabase/home/reducers";
 /* questions / query builder */
 import questions from "metabase/questions/questions";
 import labels from "metabase/questions/labels";
-import undo from "metabase/questions/undo";
+import collections from "metabase/questions/collections";
 import * as qb from "metabase/query_builder/reducers";
 
 /* data reference */
@@ -43,17 +44,18 @@ const reducers = {
     currentUser,
     metadata,
     requests,
+    undo,
 
     // main app reducers
     dashboard,
     home: combineReducers(home),
-    labels,
     pulse: combineReducers(pulse),
     qb: combineReducers(qb),
     questions,
+    collections,
+    labels,
     reference,
     setup: combineReducers(setup),
-    undo,
     user: combineReducers(user),
 
     // admin reducers
diff --git a/frontend/src/metabase/questions/undo.js b/frontend/src/metabase/redux/undo.js
similarity index 100%
rename from frontend/src/metabase/questions/undo.js
rename to frontend/src/metabase/redux/undo.js
diff --git a/frontend/src/metabase/reference/components/RevisionMessageModal.jsx b/frontend/src/metabase/reference/components/RevisionMessageModal.jsx
index 141dd115e4dfe8af3db94958dbe22095f7d2e945..5e052780d7d29b164ac7bb1f3815bb4e44e6d770 100644
--- a/frontend/src/metabase/reference/components/RevisionMessageModal.jsx
+++ b/frontend/src/metabase/reference/components/RevisionMessageModal.jsx
@@ -30,7 +30,7 @@ export default class RevisionMessageModal extends Component {
             <ModalWithTrigger ref="modal" triggerElement={children}>
                 <ModalContent
                     title="Reason for changes"
-                    closeFn={onClose}
+                    onClose={onClose}
                 >
                     <div className={S.modalBody}>
                         <textarea
diff --git a/frontend/src/metabase/reference/containers/ReferenceApp.jsx b/frontend/src/metabase/reference/containers/ReferenceApp.jsx
index 8152a108017d2a82d0fa8c23d2578703c8dc46ad..1b7de3034393e1f091b38d4b6b0373981c8d4fe7 100644
--- a/frontend/src/metabase/reference/containers/ReferenceApp.jsx
+++ b/frontend/src/metabase/reference/containers/ReferenceApp.jsx
@@ -23,7 +23,7 @@ import {
 } from '../utils';
 
 import {
-    selectSection as fetchQuestions
+    loadEntities
 } from 'metabase/questions/questions';
 
 import {
@@ -40,7 +40,7 @@ const mapStateToProps = (state, props) => ({
 });
 
 const mapDispatchToProps = {
-    fetchQuestions,
+    fetchQuestions: () => loadEntities("card", {}),
     fetchDashboards,
     ...metadataActions,
     ...actions
diff --git a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx
index 56b8be4d1501a78a37a7cc8d839f391fbcd87a49..cddcdba05be4bd22846a0dca5784a04482fd4b43 100644
--- a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx
+++ b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx
@@ -233,7 +233,7 @@ export default class ReferenceGettingStartedGuide extends Component {
 
                                 MetabaseAnalytics.trackEvent("Dashboard", "Create");
                             }}
-                            closeFn={hideDashboardModal}
+                            onClose={hideDashboardModal}
                         />
                     </Modal>
                 }
diff --git a/frontend/src/metabase/reference/selectors.js b/frontend/src/metabase/reference/selectors.js
index c0b8567139f93ceb35b43d987fcb8aa99eee29fd..d9b90fad6629dad9e6f03494bcbeb9a0ddc81e69 100644
--- a/frontend/src/metabase/reference/selectors.js
+++ b/frontend/src/metabase/reference/selectors.js
@@ -45,7 +45,7 @@ const referenceSections = {
             message: "Metrics will appear here once your admins have created some",
             image: "/app/img/metrics-list",
             adminAction: "Learn how to create metrics",
-            adminLink: "http://www.metabase.com/docs/latest/administration-guide/05-segments-and-metrics.html"
+            adminLink: "http://www.metabase.com/docs/latest/administration-guide/06-segments-and-metrics.html"
         },
         breadcrumb: "Metrics",
         // mapping of propname to args of dispatch function
@@ -65,7 +65,7 @@ const referenceSections = {
             message: "Segments will appear here once your admins have created some",
             image: "/app/img/segments-list",
             adminAction: "Learn how to create segments",
-            adminLink: "http://www.metabase.com/docs/latest/administration-guide/05-segments-and-metrics.html"
+            adminLink: "http://www.metabase.com/docs/latest/administration-guide/06-segments-and-metrics.html"
         },
         breadcrumb: "Segments",
         fetch: {
@@ -512,7 +512,9 @@ const getMetricQuestions = createSelector(
     (metricId, questions) => Object.values(questions)
         .filter(question =>
             question.dataset_query.type === "query" &&
-            AggregationClause.getMetric(question.dataset_query.query.aggregation) === metricId
+            _.any(Query.getAggregations(question.dataset_query.query), (aggregation) =>
+                AggregationClause.getMetric(aggregation) === metricId
+            )
         )
         .reduce((map, question) => i.assoc(map, question.id, question), {})
 );
diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx
index 3b385fc61112914c41376b82dabe6293e8dd8ab9..85769988f17a5069c08762dc3e086fdaa418ea06 100644
--- a/frontend/src/metabase/routes.jsx
+++ b/frontend/src/metabase/routes.jsx
@@ -12,16 +12,23 @@ import App from "metabase/App.jsx";
 // auth containers
 import ForgotPasswordApp from "metabase/auth/containers/ForgotPasswordApp.jsx";
 import LoginApp from "metabase/auth/containers/LoginApp.jsx";
-import LogoutApp from "metabase/auth/containers/LogoutApp.jsx";
-import PasswordResetApp from "metabase/auth/containers/PasswordResetApp.jsx";
+import LogoutApp from "metabase/auth/containers/LogoutApp.jsx"; import PasswordResetApp from "metabase/auth/containers/PasswordResetApp.jsx";
 import GoogleNoAccount from "metabase/auth/components/GoogleNoAccount.jsx";
 
 // main app containers
 import DashboardApp from "metabase/dashboard/containers/DashboardApp.jsx";
 import HomepageApp from "metabase/home/containers/HomepageApp.jsx";
-import EntityBrowser from "metabase/questions/containers/EntityBrowser.jsx";
-import EntityList from "metabase/questions/containers/EntityList.jsx";
+
+import QuestionIndex from "metabase/questions/containers/QuestionIndex.jsx";
+import Archive from "metabase/questions/containers/Archive.jsx";
+import CollectionPage from "metabase/questions/containers/CollectionPage.jsx";
+import CollectionEdit from "metabase/questions/containers/CollectionEdit.jsx";
+import CollectionCreate from "metabase/questions/containers/CollectionCreate.jsx";
+import SearchResults from "metabase/questions/containers/SearchResults.jsx";
 import EditLabels from "metabase/questions/containers/EditLabels.jsx";
+import CollectionPermissions from "metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx";
+import EntityList from "metabase/questions/containers/EntityList.jsx";
+
 import PulseEditApp from "metabase/pulse/containers/PulseEditApp.jsx";
 import PulseListApp from "metabase/pulse/containers/PulseListApp.jsx";
 import QueryBuilder from "metabase/query_builder/containers/QueryBuilder.jsx";
@@ -131,10 +138,27 @@ export const getRoutes = (store) =>
                 <Route path="/q" component={QueryBuilder} />
 
                 {/* QUESTIONS */}
-                <Route path="/questions" component={EntityBrowser}>
-                    <Route path="edit/labels" component={EditLabels} />
-                    <Route path=":section" component={EntityList} />
-                    <Route path=":section/:slug" component={EntityList} />
+                <Route path="/questions">
+                    <IndexRoute component={QuestionIndex} />
+                    <Route path="search" component={SearchResults} />
+                    <Route path="archive" component={Archive} />
+                    <Route path="collections/:collectionSlug" component={CollectionPage} />
+                </Route>
+
+                <Route path="/entities/:entityType" component={({ location, params }) =>
+                    <div className="p4">
+                        <EntityList entityType={params.entityType} entityQuery={location.query} />
+                    </div>
+                }/>
+
+                <Route path="/collections">
+                    <Route path="create" component={CollectionCreate} />
+                    <Route path="permissions" component={CollectionPermissions} />
+                    <Route path=":collectionId" component={CollectionEdit} />
+                </Route>
+
+                <Route path="/labels">
+                    <IndexRoute component={EditLabels} />
                 </Route>
 
                 {/* REFERENCE */}
diff --git a/frontend/src/metabase/selectors/undo.js b/frontend/src/metabase/selectors/undo.js
new file mode 100644
index 0000000000000000000000000000000000000000..852127bf143ab3ccf62e128188c74476587c52b7
--- /dev/null
+++ b/frontend/src/metabase/selectors/undo.js
@@ -0,0 +1,2 @@
+
+export const getUndos = (state, props) => state.undo;
diff --git a/frontend/src/metabase/selectors/user.js b/frontend/src/metabase/selectors/user.js
new file mode 100644
index 0000000000000000000000000000000000000000..11bc1f12f3524608544dabed8b135bd11aa4d3cd
--- /dev/null
+++ b/frontend/src/metabase/selectors/user.js
@@ -0,0 +1,6 @@
+
+export const getUser = (state) =>
+    state.currentUser;
+
+export const getUserIsAdmin = (state) =>
+    (getUser(state) || {}).is_superuser || false;
diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js
index 7b9896edd0631d24e03067f57c53cb383b4e2393..320f9a240e68a827a2eef6ed3e9934c17eeb8f80 100644
--- a/frontend/src/metabase/services.js
+++ b/frontend/src/metabase/services.js
@@ -11,7 +11,10 @@ export const ActivityApi = {
 };
 
 export const CardApi = {
-    list:                        GET("/api/card"),
+    list:                        GET("/api/card", (cards, { data }) =>
+                                    // support for the "q" query param until backend implements it
+                                    cards.filter(card => !data.q || card.name.toLowerCase().indexOf(data.q.toLowerCase()) >= 0)
+                                 ),
     create:                     POST("/api/card"),
     get:                         GET("/api/card/:cardId"),
     update:                      PUT("/api/card/:id"),
@@ -34,6 +37,16 @@ export const DashboardApi = {
     reposition_cards:            PUT("/api/dashboard/:dashId/cards"),
 };
 
+export const CollectionsApi = {
+    list:                        GET("/api/collection"),//  () => []),
+    create:                     POST("/api/collection"),
+    get:                         GET("/api/collection/:id"),
+    update:                      PUT("/api/collection/:id"),
+    delete:                   DELETE("/api/collection/:id"),
+    graph:                       GET("/api/collection/graph"),
+    updateGraph:                 PUT("/api/collection/graph"),
+};
+
 export const EmailApi = {
     updateSettings:              PUT("/api/email"),
     sendTest:                   POST("/api/email/test"),
@@ -181,3 +194,5 @@ export const UserApi = {
 export const UtilApi = {
     password_check:             POST("/api/util/password_check"),
 };
+
+global.services = exports;
diff --git a/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx b/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx
index 6d26a3a2e9f9c63368f64aeee00a0d7eca3927bc..ad9d0c1817b5c136d80f3e8c094ea6b849622aaf 100644
--- a/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx
+++ b/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx
@@ -22,7 +22,7 @@ const QUERY_BUILDER_STEPS = [
         getModalTarget: () => qs(".GuiBuilder-data"),
         getModal: (props) =>
             <div className="text-centered">
-                <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/table.png" width={157} />
+                <RetinaImage id="QB-TutorialTableImg" className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/table.png" width={157} />
                 <h3>Start by picking the table with the data that you have a question about.</h3>
                 <p>Go ahead and select the "Orders" table from the dropdown menu.</p>
             </div>,
@@ -44,7 +44,13 @@ const QUERY_BUILDER_STEPS = [
         getModalTarget: () => qs(".GuiBuilder-filtered-by"),
         getModal: (props) =>
             <div className="text-centered">
-                <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/funnel.png" width={135} />
+                <RetinaImage
+                    className="mb2"
+                    forceOriginalDimensions={false}
+                    id="QB-TutorialFunnelImg"
+                    src="/app/img/qb_tutorial/funnel.png"
+                    width={135}
+                />
                 <h3>Filter your data to get just what you want.</h3>
                 <p>Click the plus button and select the "Created At" field.</p>
             </div>,
@@ -57,9 +63,9 @@ const QUERY_BUILDER_STEPS = [
     },
     {
         getPortalTarget: () => qs(".GuiBuilder-filtered-by"),
-        getPageFlagText: () => "This will let us select only orders that were created this year",
-        getPageFlagTarget: () => qs('[data-ui-tag="relative-date-shortcut-this-year"]'),
-        shouldAllowEvent: (e) => qs('[data-ui-tag="relative-date-shortcut-this-year"]').contains(e.target)
+        getPageFlagText: () => "Here we can pick how many days we want to see data for, try 10",
+        getPageFlagTarget: () => qs('[data-ui-tag="relative-date-input"]'),
+        shouldAllowEvent: (e) => qs('[data-ui-tag="relative-date-input"]').contains(e.target)
     },
     {
         getPortalTarget: () => qs(".GuiBuilder-filtered-by"),
@@ -71,7 +77,13 @@ const QUERY_BUILDER_STEPS = [
         getModalTarget: () => qs(".Query-section-aggregation"),
         getModal: (props) =>
             <div className="text-centered">
-                <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/calculator.png" width={115} />
+                <RetinaImage
+                    className="mb2"
+                    forceOriginalDimensions={false}
+                    id="QB-TutorialCalculatorImg"
+                    src="/app/img/qb_tutorial/calculator.png"
+                    width={115}
+                />
                 <h3>Here's where you can choose to add or average your data, count the number of rows in the table, or just view the raw data.</h3>
                 <p>Try it: click on <strong>Raw Data</strong> to change it to <strong>Count of rows</strong> so we can count how many orders there are in this table.</p>
             </div>,
@@ -87,7 +99,13 @@ const QUERY_BUILDER_STEPS = [
         getModalTarget: () => qs(".Query-section-breakout"),
         getModal: (props) =>
             <div className="text-centered">
-                <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/banana.png" width={232} />
+                <RetinaImage
+                    className="mb2"
+                    forceOriginalDimensions={false}
+                    id="QB-TutorialBananaImg"
+                    src="/app/img/qb_tutorial/banana.png"
+                    width={232}
+                />
                 <h3>Add a grouping to break out your results by category, day, month, and more.</h3>
                 <p>Let's do it: click on <strong>Add a grouping</strong>, and choose <strong>Created At: by Week</strong>.</p>
             </div>,
@@ -109,7 +127,13 @@ const QUERY_BUILDER_STEPS = [
         getModalTarget: () => qs(".RunButton"),
         getModal: (props) =>
             <div className="text-centered">
-                <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/rocket.png" width={217} />
+                <RetinaImage
+                    className="mb2"
+                    forceOriginalDimensions={false}
+                    id="QB-TutorialRocketImg"
+                    src="/app/img/qb_tutorial/rocket.png"
+                    width={217}
+                />
                 <h3>Run Your Query.</h3>
                 <p>You're doing so well! Click <strong>Run query</strong> to get your results!</p>
             </div>,
@@ -120,7 +144,13 @@ const QUERY_BUILDER_STEPS = [
         getModalTarget: () => qs(".VisualizationSettings"),
         getModal: (props) =>
             <div className="text-centered">
-                <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/chart.png" width={160} />
+                <RetinaImage
+                    className="mb2"
+                    forceOriginalDimensions={false}
+                    id="QB-TutorialChartImg"
+                    src="/app/img/qb_tutorial/chart.png"
+                    width={160}
+                />
                 <h3>You can view your results as a chart instead of a table.</h3>
                 <p>Everbody likes charts! Click the <strong>Visualization</strong> dropdown and select <strong>Line</strong>.</p>
             </div>,
@@ -135,7 +165,12 @@ const QUERY_BUILDER_STEPS = [
         getPortalTarget: () => true,
         getModal: (props) =>
             <div className="text-centered">
-                <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/boat.png" width={190} />
+                <RetinaImage
+                    className="mb2"
+                    forceOriginalDimensions={false}
+                    id="QB-TutorialBoatImg"
+                    src="/app/img/qb_tutorial/boat.png" width={190}
+                />
                 <h3>Well done!</h3>
                 <p>That's all! If you still have questions, check out our <a className="link" target="_blank" href="http://www.metabase.com/docs/latest/users-guide/start">User's Guide</a>. Have fun exploring your data!</p>
                 <a className="Button Button--primary" onClick={props.onNext}>Thanks!</a>
diff --git a/frontend/src/metabase/tutorial/Tutorial.jsx b/frontend/src/metabase/tutorial/Tutorial.jsx
index 7cb835d6e63d5e9f393350c24faef15b9d27da0a..059b17e514f9c22eaf683a44b10d997d433ae2c8 100644
--- a/frontend/src/metabase/tutorial/Tutorial.jsx
+++ b/frontend/src/metabase/tutorial/Tutorial.jsx
@@ -202,7 +202,7 @@ export default class Tutorial extends Component {
         let step = this.props.steps[this.state.step];
 
         if (!step) {
-            return <span />;
+            return null;
         }
 
         const { missingTarget, pageFlagTarget, portalTarget, modalTarget } = this.getTargets(step);
diff --git a/frontend/src/metabase/visualizations/PinMap.jsx b/frontend/src/metabase/visualizations/PinMap.jsx
index 37f9887aca3052fd43afc69281a2a59fb19eb4f9..a43c7025d1d211c2794ba96a2e9db8f8c98ba021 100644
--- a/frontend/src/metabase/visualizations/PinMap.jsx
+++ b/frontend/src/metabase/visualizations/PinMap.jsx
@@ -74,8 +74,8 @@ export default class PinMap extends Component {
         const latitudeIndex = _.findIndex(cols, (col) => col.name === settings["map.latitude_column"]);
         const longitudeIndex = _.findIndex(cols, (col) => col.name === settings["map.longitude_column"]);
         const points = rows.map(row => [
-            row[longitudeIndex],
-            row[latitudeIndex]
+            row[latitudeIndex],
+            row[longitudeIndex]
         ]);
         const bounds = L.latLngBounds(points);
         return { points, bounds };
diff --git a/frontend/src/metabase/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/Scalar.jsx
index fe438d7d042396b53cdbf0c8c3f9fafc7d4cf152..3b08089c20d4eb17c3b59efe753a31cedf55e63b 100644
--- a/frontend/src/metabase/visualizations/Scalar.jsx
+++ b/frontend/src/metabase/visualizations/Scalar.jsx
@@ -124,7 +124,12 @@ export default class Scalar extends Component {
         return (
             <div className={cx(className, styles.Scalar, styles[isSmall ? "small" : "large"])}>
                 <div className="Card-title absolute top right p1 px2">{actionButtons}</div>
-                <Ellipsified className={cx(styles.Value, 'ScalarValue', 'fullscreen-normal-text', 'fullscreen-night-text')} tooltip={fullScalarValue} alwaysShowTooltip={fullScalarValue !== compactScalarValue}>
+                <Ellipsified
+                    className={cx(styles.Value, 'ScalarValue', 'fullscreen-normal-text', 'fullscreen-night-text')}
+                    tooltip={fullScalarValue}
+                    alwaysShowTooltip={fullScalarValue !== compactScalarValue}
+                    style={{maxWidth: '100%'}}
+                >
                     {compactScalarValue}
                 </Ellipsified>
                 <Ellipsified className={styles.Title} tooltip={card.name}>
diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
index c08db35589323e42ccd13ade0fdbf60045f805a8..ff4f99e33ee3e01f0499f4fc8ac7b4709ee45316 100644
--- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
+++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
@@ -278,6 +278,10 @@ function applyChartTooltips(chart, series, onHoverChange) {
                         { key: getFriendlyName(cols[index]), value: value, col: cols[index] }
                     ));
                 } else if (d.data) { // line, area, bar
+                    if (!isSingleSeriesBar) {
+                        let idx = determineSeriesIndexFromElement(this);
+                        cols = series[idx].data.cols;
+                    }
                     data = [
                         { key: getFriendlyName(cols[0]), value: d.data.key, col: cols[0] },
                         { key: getFriendlyName(cols[1]), value: d.data.value, col: cols[1] }
@@ -711,7 +715,8 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender,
         if (isTimeseries) {
             // replace xValues with
             xValues = d3.time[xInterval.interval]
-                .range(xDomain[0], moment(xDomain[1]).add(1, "ms"), xInterval.count);
+                .range(xDomain[0], moment(xDomain[1]).add(1, "ms"), xInterval.count)
+                .map(d => moment(d));
             datas = fillMissingValues(
                 datas,
                 xValues,
@@ -802,7 +807,9 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender,
     let yExtents = groups.map(group => d3.extent(group[0].all(), d => d.value));
     let yExtent = d3.extent([].concat(...yExtents));
 
-    if (!isScalarSeries && !isScatter && !isStacked && settings["graph.y_axis.auto_split"] !== false) {
+    // don't auto-split if the metric columns are all identical, i.e. it's a breakout multiseries
+    const hasDifferentYAxisColumns = _.uniq(series.map(s => s.data.cols[1])).length > 1;
+    if (!isScalarSeries && !isScatter && !isStacked && hasDifferentYAxisColumns && settings["graph.y_axis.auto_split"] !== false) {
         yAxisSplit = computeSplit(yExtents);
     } else {
         yAxisSplit = [series.map((s,i) => i)];
diff --git a/frontend/test/.eslintrc b/frontend/test/.eslintrc
new file mode 100644
index 0000000000000000000000000000000000000000..820d4aa1ab191f221199a6affa7c6c4657de7175
--- /dev/null
+++ b/frontend/test/.eslintrc
@@ -0,0 +1,13 @@
+{
+    "rules": {
+        "jasmine/no-focused-tests": 2,
+        "jasmine/no-suite-dupes": [2, "branch"]
+    },
+    "env": {
+        "jasmine": true,
+        "node": true
+    },
+    "plugins": [
+        "jasmine"
+    ]
+}
diff --git a/frontend/test/e2e/admin/datamodel.spec.js b/frontend/test/e2e/admin/datamodel.spec.js
index e8215354b6290074146de737ae43b95d2bb99f7b..fc129af68e27decc42573b81b1ea995b13340628 100644
--- a/frontend/test/e2e/admin/datamodel.spec.js
+++ b/frontend/test/e2e/admin/datamodel.spec.js
@@ -1,16 +1,9 @@
-
-import { By, until } from "selenium-webdriver";
-
 import {
-    waitForElement,
     waitForElementText,
-    waitForElementRemoved,
     findElement,
     waitForElementAndClick,
     waitForElementAndSendKeys,
-    waitForUrl,
     screenshot,
-    loginMetabase,
     ensureLoggedIn,
     describeE2E
 } from "../support/utils";
@@ -61,7 +54,7 @@ describeE2E("admin/datamodel", () => {
             await waitForElementAndClick(driver, ".GuiBuilder-filtered-by a");
             await waitForElementAndClick(driver, "#FilterPopover .List-item:nth-child(4)>a");
             const addFilterButton = findElement(driver, "#FilterPopover .Button.disabled");
-            await waitForElementAndClick(driver, "#OperatorSelector .Button.Button-normal.Button--medium:nth-child(3)");
+            await waitForElementAndClick(driver, "#OperatorSelector .Button.Button-normal.Button--medium:nth-child(2)");
             await waitForElementAndSendKeys(driver, "#FilterPopover input.border-purple", 'gmail');
             expect(await addFilterButton.isEnabled()).toBe(true);
             await addFilterButton.click();
diff --git a/frontend/test/e2e/admin/people.spec.js b/frontend/test/e2e/admin/people.spec.js
index 38d2af293bc0d13094edd98a97bcf8e2ed4be0ee..07eb5177c9d4711a508b2b68a09642f76f60421d 100644
--- a/frontend/test/e2e/admin/people.spec.js
+++ b/frontend/test/e2e/admin/people.spec.js
@@ -1,13 +1,10 @@
-
-import { By, until } from "selenium-webdriver";
-
 import {
     waitForElement,
     waitForElementText,
-    waitForElementRemoved,
     findElement,
     waitForElementAndClick,
     waitForElementAndSendKeys,
+    waitForElementRemoved,
     waitForUrl,
     screenshot,
     loginMetabase,
@@ -35,7 +32,7 @@ describeE2E("admin/people", () => {
             await waitForElementAndClick(driver, ".Button.Button--primary");
 
             // fill in user info form
-            const addButton = findElement(driver, ".Modal .Button[disabled]");
+            const addButton = findElement(driver, ".ModalContent .Button[disabled]");
             await waitForElementAndSendKeys(driver, "[name=firstName]", firstName);
             await waitForElementAndSendKeys(driver, "[name=lastName]", lastName);
             await waitForElementAndSendKeys(driver, "[name=email]", email);
@@ -60,7 +57,7 @@ describeE2E("admin/people", () => {
             await waitForElementAndClick(driver, ".ContentTable tr:first-child td:last-child a");
             await waitForElementAndClick(driver, ".UserActionsSelect li:first-child");
 
-            const saveButton = findElement(driver, ".Modal .Button[disabled]");
+            const saveButton = findElement(driver, ".ModalContent .Button[disabled]");
             await waitForElementAndSendKeys(driver, "[name=firstName]", `${firstName}x`);
             await waitForElementAndSendKeys(driver, "[name=lastName]", `${lastName}x`);
             await waitForElementAndSendKeys(driver, "[name=email]", `${email}x`);
@@ -71,6 +68,7 @@ describeE2E("admin/people", () => {
             await waitForElementText(driver, ".ContentTable tr:first-child td:nth-child(3)", `${email}x`);
 
             // reset user password
+            await waitForElementRemoved(driver, ".Modal");
             await waitForElementAndClick(driver, ".ContentTable tr:first-child td:last-child a");
             await waitForElementAndClick(driver, ".UserActionsSelect li:nth-child(2)");
 
diff --git a/frontend/test/e2e/auth/login.spec.js b/frontend/test/e2e/auth/login.spec.js
index 9416af4f9d0f3d1a6be6838c1f289671ae231e9a..d67f0476d4aff0f55317f1b3a5c71743fda8e77e 100644
--- a/frontend/test/e2e/auth/login.spec.js
+++ b/frontend/test/e2e/auth/login.spec.js
@@ -1,13 +1,6 @@
 
-import { By, until } from "selenium-webdriver";
-
+import { By } from "selenium-webdriver";
 import {
-    waitForElement,
-    waitForElementText,
-    waitForElementRemoved,
-    findElement,
-    waitForElementAndClick,
-    waitForElementAndSendKeys,
     waitForUrl,
     screenshot,
     loginMetabase,
diff --git a/frontend/test/e2e/query_builder/query_builder.spec.js b/frontend/test/e2e/query_builder/query_builder.spec.js
index 51131f377dca5b00a0b4cc86b08204231875222e..98f8ad03932ea2b7242396b650303898f4c0fefd 100644
--- a/frontend/test/e2e/query_builder/query_builder.spec.js
+++ b/frontend/test/e2e/query_builder/query_builder.spec.js
@@ -1,16 +1,9 @@
 
-import { By, until } from "selenium-webdriver";
-
 import {
-    waitForElement,
-    waitForElementText,
     waitForElementRemoved,
-    findElement,
     waitForElementAndClick,
     waitForElementAndSendKeys,
-    waitForUrl,
     screenshot,
-    loginMetabase,
     describeE2E,
     ensureLoggedIn
 } from "../support/utils";
diff --git a/frontend/test/e2e/query_builder/tutorial.spec.js b/frontend/test/e2e/query_builder/tutorial.spec.js
index d1a4b6183421ecf2ddd1aa8eb874bb79a85be8d0..8b6b31f589a2becb8170a0bac563025c3ad08501 100644
--- a/frontend/test/e2e/query_builder/tutorial.spec.js
+++ b/frontend/test/e2e/query_builder/tutorial.spec.js
@@ -1,15 +1,10 @@
-import { By, until } from "selenium-webdriver";
-
 import {
     waitForElement,
-    waitForElementText,
     waitForElementRemoved,
-    findElement,
     waitForElementAndClick,
     waitForElementAndSendKeys,
     waitForUrl,
     screenshot,
-    loginMetabase,
     describeE2E,
     ensureLoggedIn
 } from "../support/utils";
@@ -34,7 +29,7 @@ describeE2E("tutorial", () => {
         await screenshot(driver, "screenshots/setup-tutorial-qb.png");
         await waitForElementAndClick(driver, ".Modal .Button.Button--primary");
 
-        await waitForElement(driver, "img[src='/app/img/qb_tutorial/table.png']");
+        await waitForElement(driver, "#QB-TutorialTableImg");
         // a .Modal-backdrop element blocks clicks for a while during transition?
         await waitForElementRemoved(driver, '.Modal-backdrop');
         await waitForElementAndClick(driver, ".GuiBuilder-data a");
@@ -50,32 +45,33 @@ describeE2E("tutorial", () => {
         await waitForElementAndClick(driver, "#TablePicker .List-item:first-child>a");
 
         // select filters
-        await waitForElement(driver, "img[src='/app/img/qb_tutorial/funnel.png']");
+        await waitForElement(driver, "#QB-TutorialFunnelImg");
         await waitForElementAndClick(driver, ".GuiBuilder-filtered-by .Query-section:not(.disabled) a");
 
         await waitForElementAndClick(driver, "#FilterPopover .List-item:first-child>a");
 
-        await waitForElementAndClick(driver, ".Button[data-ui-tag='relative-date-shortcut-this-year']");
+        await waitForElementAndClick(driver, "input[data-ui-tag='relative-date-input']");
+        await waitForElementAndSendKeys(driver, "#FilterPopover input.border-purple", '10');
         await waitForElementAndClick(driver, ".Button[data-ui-tag='add-filter']:not(.disabled)");
 
         // select aggregations
-        await waitForElement(driver, "img[src='/app/img/qb_tutorial/calculator.png']");
+        await waitForElement(driver, "#QB-TutorialCalculatorImg");
         await waitForElementAndClick(driver, "#Query-section-aggregation");
         await waitForElementAndClick(driver, "#AggregationPopover .List-item:nth-child(2)>a");
 
         // select breakouts
-        await waitForElement(driver, "img[src='/app/img/qb_tutorial/banana.png']");
+        await waitForElement(driver, "#QB-TutorialBananaImg");
         await waitForElementAndClick(driver, ".Query-section.Query-section-breakout>div");
 
         await waitForElementAndClick(driver, "#BreakoutPopover .List-item:first-child .Field-extra>a");
-        await waitForElementAndClick(driver, "#TimeGroupingPopover .List-item:nth-child(3)>a");
+        await waitForElementAndClick(driver, "#TimeGroupingPopover .List-item:nth-child(4)>a");
 
         // run query
-        await waitForElement(driver, "img[src='/app/img/qb_tutorial/rocket.png']");
+        await waitForElement(driver, "#QB-TutorialRocketImg");
         await waitForElementAndClick(driver, ".Button.RunButton");
 
         // wait for query to complete
-        await waitForElement(driver, "img[src='/app/img/qb_tutorial/chart.png']", 20000);
+        await waitForElement(driver, "#QB-TutorialChartImg", 20000);
 
         // switch visualization
         await waitForElementAndClick(driver, "#VisualizationTrigger");
@@ -84,7 +80,7 @@ describeE2E("tutorial", () => {
         await waitForElementAndClick(driver, "#VisualizationPopover li:nth-child(4)");
 
         // end tutorial
-        await waitForElement(driver, "img[src='/app/img/qb_tutorial/boat.png']");
+        await waitForElement(driver, "#QB-TutorialBoatImg");
         await waitForElementAndClick(driver, ".Modal .Button.Button--primary");
         await waitForElementAndClick(driver, ".PopoverBody .Button.Button--primary");
 
diff --git a/frontend/test/e2e/setup/signup.spec.js b/frontend/test/e2e/setup/signup.spec.js
index 57c74606bce394f1ea92f2f6053ed3350f612fad..3303d7d58319dbe8e0c7045734c94e0e29a8ee31 100644
--- a/frontend/test/e2e/setup/signup.spec.js
+++ b/frontend/test/e2e/setup/signup.spec.js
@@ -1,17 +1,12 @@
 import path from "path";
 
-import { By, until } from "selenium-webdriver";
-
 import {
     waitForElement,
-    waitForElementText,
-    waitForElementRemoved,
     findElement,
     waitForElementAndClick,
     waitForElementAndSendKeys,
     waitForUrl,
     screenshot,
-    loginMetabase,
     describeE2E
 } from "../support/utils";
 
diff --git a/frontend/test/e2e/support/shared-resource.js b/frontend/test/e2e/support/shared-resource.js
index 6d3ec98fedc15b9909726104b39057480cf2af14..efb639af73f5b4805c4458c2a7cc24db356ae145 100644
--- a/frontend/test/e2e/support/shared-resource.js
+++ b/frontend/test/e2e/support/shared-resource.js
@@ -22,7 +22,7 @@ export default function createSharedResource(resourceName, {
         return Promise.all(exitPromises);
     })
 
-    async function kill(entry) {
+    function kill(entry) {
         if (entriesByKey.has(entry.key)) {
             entriesByKey.delete(entry.key);
             entriesByResource.delete(entry.resource);
diff --git a/frontend/test/e2e/support/utils.js b/frontend/test/e2e/support/utils.js
index 92c460d5abe84e1db6b13a2691f56a070f7e2bad..feb21b0e4911546ada35d4f3ce8e964e53f13179 100644
--- a/frontend/test/e2e/support/utils.js
+++ b/frontend/test/e2e/support/utils.js
@@ -7,8 +7,6 @@ import { Driver } from "webchauffeur";
 
 const DEFAULT_TIMEOUT = 50000;
 
-const delay = (timeout = 0) => new Promise((resolve) => setTimeout(resolve, timeout));
-
 const log = (message) => {
     console.log(message);
 };
diff --git a/frontend/test/karma.conf.js b/frontend/test/karma.conf.js
index 80e628aeee1db342bdf753fee5f142c357684710..93c9bb6f7aa1f628dd752316c46e8935d9b92bdc 100644
--- a/frontend/test/karma.conf.js
+++ b/frontend/test/karma.conf.js
@@ -36,9 +36,6 @@ module.exports = function(config) {
         webpackMiddleware: {
             stats: "errors-only"
         },
-        webpackMiddleware: {
-            stats: "errors-only",
-        },
         coverageReporter: {
             dir: '../coverage/',
             subdir: function(browser) {
diff --git a/frontend/test/unit/lib/dom.spec.js b/frontend/test/unit/lib/dom.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..b1fe6d439e8b490266ae0a3355bf23c7d926685f
--- /dev/null
+++ b/frontend/test/unit/lib/dom.spec.js
@@ -0,0 +1,29 @@
+import { getSelectionPosition, setSelectionPosition } from "metabase/lib/dom"
+
+describe("getSelectionPosition/setSelectionPosition", () => {
+    let container;
+    beforeEach(() => {
+        container = document.createElement("div");
+        document.body.appendChild(container);
+    })
+    afterEach(() => {
+        document.body.removeChild(container);
+    })
+
+    it("should get/set selection on input correctly", () => {
+        let input = document.createElement("input");
+        container.appendChild(input);
+        input.value = "hello world";
+        setSelectionPosition(input, [3, 6]);
+        const position = getSelectionPosition(input);
+        expect(position).toEqual([3, 6]);
+    });
+    it("should get/set selection on contenteditable correctly", () => {
+        let contenteditable = document.createElement("div");
+        container.appendChild(contenteditable);
+        contenteditable.textContent = "<div>hello world</div>"
+        setSelectionPosition(contenteditable, [3, 6]);
+        const position = getSelectionPosition(contenteditable);
+        expect(position).toEqual([3, 6]);
+    });
+})
diff --git a/frontend/test/unit/lib/expressions.spec.js b/frontend/test/unit/lib/expressions.spec.js
deleted file mode 100644
index e15ae023e1b159379dec60c360817885f5be57c5..0000000000000000000000000000000000000000
--- a/frontend/test/unit/lib/expressions.spec.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import _ from "underscore";
-import { formatExpression, parseExpressionString, tokenAtPosition, tokensToExpression } from "metabase/lib/expressions";
-
-const mockFields = [
-    {id: 1, display_name: "A"},
-    {id: 2, display_name: "B"},
-    {id: 3, display_name: "C"},
-    {id: 10, display_name: "Toucan Sam"}
-];
-
-const mathOperators = new Set(['+', '-', '*', '/']);
-
-const parsedMathOperators = {
-    "+": { value: '+', start: 2, end: 3, parsedValue: '+' },
-    "-": { value: '-', start: 2, end: 3, parsedValue: '-' },
-    "*": { value: '*', start: 2, end: 3, parsedValue: '*' },
-    "/": { value: '/', start: 2, end: 3, parsedValue: '/' }
-}
-
-function stripStartEnd(list) {
-    return list.map(i => {
-        delete i.start;
-        delete i.end;
-
-        if (_.isArray(i.value)) {
-            i.value = stripStartEnd(i.value);
-        }
-
-        return i;
-    });
-}
-
-
-describe("parseExpressionString", () => {
-    it("should return empty array for null or empty string", () => {
-        expect(parseExpressionString()).toEqual([]);
-        expect(parseExpressionString(null)).toEqual([]);
-        expect(parseExpressionString("")).toEqual([]);
-    });
-
-    // simplest possible expression
-    it("can parse single operator math", () => {
-        expect(stripStartEnd(parseExpressionString("A - B", mockFields, mathOperators)))
-        .toEqual([
-            { value: 'A', suggestions: [mockFields[0], mockFields[3]], parsedValue: [ 'field-id', 1 ] },
-            { value: '-', parsedValue: '-' },
-            { value: 'B', suggestions: [], parsedValue: [ 'field-id', 2 ] }
-        ]);
-    });
-
-    // quoted field name w/ a space in it
-    it("can parse a field with quotes and spaces", () => {
-        expect(stripStartEnd(parseExpressionString("\"Toucan Sam\" + B", mockFields, mathOperators)))
-        .toEqual([
-            { value: 'Toucan Sam', suggestions: [], parsedValue: [ 'field-id', 10 ] },
-            { value: '+', parsedValue: '+' },
-            { value: 'B', suggestions: [], parsedValue: [ 'field-id', 2 ] }
-        ]);
-    });
-
-    // parentheses / nested parens
-    it("can parse expressions with parentheses", () => {
-        expect(stripStartEnd(parseExpressionString("\"Toucan Sam\" + (A * (B / C))", mockFields, mathOperators)))
-        .toEqual([
-            { value: 'Toucan Sam', suggestions: [], parsedValue: [ 'field-id', 10 ] },
-            { value: '+', parsedValue: '+' },
-            { value: [{ value: 'A', suggestions: [mockFields[0], mockFields[3]], parsedValue: [ 'field-id', 1 ] },
-                      { value: '*', parsedValue: '*' },
-                      { value: [{ value: 'B', suggestions: [], parsedValue: [ 'field-id', 2 ] },
-                                { value: '/', parsedValue: '/' },
-                                { value: 'C', suggestions: [mockFields[2], mockFields[3]], parsedValue: [ 'field-id', 3 ] }],
-                        isParent: true}],
-              isParent: true }
-        ]);
-    });
-
-    // fks
-    // multiple tables with the same field name resolution
-});
-
-
-describe("formatExpression", () => {
-    it("should return empty array for null or empty string", () => {
-        expect(parseExpressionString()).toEqual([]);
-        expect(parseExpressionString(null)).toEqual([]);
-        expect(parseExpressionString("")).toEqual([]);
-    });
-
-    it("can format simple expressions", () => {
-        expect(formatExpression(["+", ["field-id", 1], ["field-id", 2]], mockFields)).toEqual("A + B");
-    });
-
-    it("can format expressions with parentheses", () => {
-        expect(formatExpression(["+", ["/", ["field-id", 1], ["field-id", 2]], ["field-id", 3]], mockFields)).toEqual("(A / B) + C");
-        expect(formatExpression(["+", ["/", ["field-id", 1], ["*", ["field-id", 2], ["field-id", 2]]], ["field-id", 3]], mockFields)).toEqual("(A / (B * B)) + C");
-    });
-
-    it("quotes fields with spaces in them", () => {
-        expect(formatExpression(["+", ["/", ["field-id", 1], ["field-id", 10]], ["field-id", 3]], mockFields)).toEqual("(A / \"Toucan Sam\") + C");
-    });
-});
diff --git a/frontend/test/unit/lib/expressions/formatter.spec.js b/frontend/test/unit/lib/expressions/formatter.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..0cd7715fcd1d7239ceb32fd779732e7450ac4fc8
--- /dev/null
+++ b/frontend/test/unit/lib/expressions/formatter.spec.js
@@ -0,0 +1,54 @@
+import { format } from "metabase/lib/expressions/formatter";
+
+const mockMetadata = {
+    tableMetadata: {
+        fields: [
+            {id: 1, display_name: "A"},
+            {id: 2, display_name: "B"},
+            {id: 3, display_name: "C"},
+            {id: 10, display_name: "Toucan Sam"}
+        ],
+        metrics: [
+            {id: 1, name: "foo bar"},
+        ]
+    }
+}
+
+describe("lib/expressions/parser", () => {
+    describe("format", () => {
+        it("can format simple expressions", () => {
+            expect(format(["+", ["field-id", 1], ["field-id", 2]], mockMetadata)).toEqual("A + B");
+        });
+
+        it("can format expressions with parentheses", () => {
+            expect(format(["+", ["/", ["field-id", 1], ["field-id", 2]], ["field-id", 3]], mockMetadata)).toEqual("(A / B) + C");
+            expect(format(["+", ["/", ["field-id", 1], ["*", ["field-id", 2], ["field-id", 2]]], ["field-id", 3]], mockMetadata)).toEqual("(A / (B * B)) + C");
+        });
+
+        it("quotes fields with spaces in them", () => {
+            expect(format(["+", ["/", ["field-id", 1], ["field-id", 10]], ["field-id", 3]], mockMetadata)).toEqual("(A / \"Toucan Sam\") + C");
+        });
+
+        it("format aggregations", () => {
+            expect(format(["count"], mockMetadata)).toEqual("Count");
+            expect(format(["sum", ["field-id", 1]], mockMetadata)).toEqual("Sum(A)");
+        });
+
+        it("nested aggregation", () => {
+            expect(format(["+", 1, ["count"]], mockMetadata)).toEqual("1 + Count");
+            expect(format(["/", ["sum", ["field-id", 1]], ["count"]], mockMetadata)).toEqual("Sum(A) / Count");
+        });
+
+        it("aggregation with expressions", () => {
+            expect(format(["sum", ["/", ["field-id", 1], ["field-id", 2]]], mockMetadata)).toEqual("Sum(A / B)");
+        });
+
+        it("expression with metric", () => {
+            expect(format(["+", 1, ["METRIC", 1]], mockMetadata)).toEqual("1 + \"foo bar\"");
+        });
+
+        it("expression with custom field", () => {
+            expect(format(["+", 1, ["sum", ["expression", "foo bar"]]], mockMetadata)).toEqual("1 + Sum(\"foo bar\")");
+        });
+    });
+});
diff --git a/frontend/test/unit/lib/expressions/parser.spec.js b/frontend/test/unit/lib/expressions/parser.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..151c27319432d494072983bbdb11929088274dbc
--- /dev/null
+++ b/frontend/test/unit/lib/expressions/parser.spec.js
@@ -0,0 +1,154 @@
+import { compile, suggest, parse } from "metabase/lib/expressions/parser";
+import _ from "underscore";
+
+const mockMetadata = {
+    tableMetadata: {
+        fields: [
+            {id: 1, display_name: "A"},
+            {id: 2, display_name: "B"},
+            {id: 3, display_name: "C"},
+            {id: 10, display_name: "Toucan Sam"}
+        ],
+        metrics: [
+            {id: 1, name: "foo bar"},
+        ],
+        aggregation_options: [
+            { short: "count", fields: [] },
+            { short: "sum", fields: [[]] }
+        ]
+    }
+}
+
+const expressionOpts = { ...mockMetadata, startRule: "expression" };
+const aggregationOpts = { ...mockMetadata, startRule: "aggregation" };
+
+describe("lib/expressions/parser", () => {
+    describe("compile()", () => {
+        it("should return empty array for null or empty string", () => {
+            expect(compile()).toEqual([]);
+            expect(compile(null)).toEqual([]);
+            expect(compile("")).toEqual([]);
+        });
+
+        it("can parse simple expressions", () => {
+            expect(compile("A", expressionOpts)).toEqual(['field-id', 1]);
+            expect(compile("1", expressionOpts)).toEqual(1);
+            expect(compile("1.1", expressionOpts)).toEqual(1.1);
+        });
+
+        it("can parse single operator math", () => {
+            expect(compile("A-B", expressionOpts)).toEqual(["-", ['field-id', 1], ['field-id', 2]]);
+            expect(compile("A - B", expressionOpts)).toEqual(["-", ['field-id', 1], ['field-id', 2]]);
+            expect(compile("1 - B", expressionOpts)).toEqual(["-", 1, ['field-id', 2]]);
+            expect(compile("1 - 2", expressionOpts)).toEqual(["-", 1, 2]);
+        });
+
+        it("can handle operator precedence", () => {
+            expect(compile("1 + 2 * 3", expressionOpts)).toEqual(["+", 1, ["*", 2, 3]]);
+            expect(compile("1 * 2 + 3", expressionOpts)).toEqual(["+", ["*", 1, 2], 3]);
+        });
+
+        it("can collapse consecutive identical operators", () => {
+            expect(compile("1 + 2 + 3 * 4 * 5", expressionOpts)).toEqual(["+", 1, 2, ["*", 3, 4, 5]]);
+        });
+
+        it("can handle negative number literals", () => {
+            expect(compile("1 + -1", expressionOpts)).toEqual(["+", 1, -1]);
+        });
+
+        // quoted field name w/ a space in it
+        it("can parse a field with quotes and spaces", () => {
+            expect(compile("\"Toucan Sam\" + B", expressionOpts)).toEqual(["+", ['field-id', 10], ['field-id', 2]]);
+        });
+
+        // parentheses / nested parens
+        it("can parse expressions with parentheses", () => {
+            expect(compile("(1 + 2) * 3", expressionOpts)).toEqual(["*", ["+", 1, 2], 3]);
+            expect(compile("1 * (2 + 3)", expressionOpts)).toEqual(["*", 1, ["+", 2, 3]]);
+            expect(compile("\"Toucan Sam\" + (A * (B / C))", expressionOpts)).toEqual(
+                ["+", ['field-id', 10], ["*", [ 'field-id', 1 ], ["/", [ 'field-id', 2 ], [ 'field-id', 3 ]]]]
+            );
+        });
+
+        it("can parse aggregation with no arguments", () => {
+            expect(compile("Count", aggregationOpts)).toEqual(["count"]);
+            expect(compile("Count()", aggregationOpts)).toEqual(["count"]);
+        });
+
+        it("can parse aggregation with argument", () => {
+            expect(compile("Sum(A)", aggregationOpts)).toEqual(["sum", ["field-id", 1]]);
+        });
+
+        it("can handle negative number literals in aggregations", () => {
+            expect(compile("-1 * Count", aggregationOpts)).toEqual(["*", -1, ["count"]]);
+        });
+
+        it("can parse complex aggregation", () => {
+            expect(compile("1 - Sum(A * 2) / Count", aggregationOpts)).toEqual(["-", 1, ["/", ["sum", ["*", ["field-id", 1], 2]], ["count"]]]);
+        });
+
+        it("should throw exception on invalid input", () => {
+            expect(() => compile("1 + ", expressionOpts)).toThrow();
+        });
+
+        // fks
+        // multiple tables with the same field name resolution
+    });
+
+    describe("suggest()", () => {
+        it("should suggest aggregations and metrics after an operator", () => {
+            expect(cleanSuggestions(suggest("1 + ", aggregationOpts))).toEqual([
+                { type: 'aggregations', text: 'Count ' },
+                { type: 'aggregations', text: 'Sum(' },
+                // NOTE: metrics support currently disabled
+                // { type: 'metrics',     text: '"foo bar"' },
+                { type: 'other',       text: ' (' },
+            ]);
+        })
+        it("should suggest fields after an operator", () => {
+            expect(cleanSuggestions(suggest("1 + ", expressionOpts))).toEqual([
+                { type: 'fields',      text: '"Toucan Sam" ' },
+                { type: 'fields',      text: 'A ' },
+                { type: 'fields',      text: 'B ' },
+                { type: 'fields',      text: 'C ' },
+                { type: 'other',       text: ' (' },
+            ]);
+        })
+        it("should suggest partial matches in aggregation", () => {
+            expect(cleanSuggestions(suggest("1 + C", aggregationOpts))).toEqual([
+                { type: 'aggregations', text: 'Count ' },
+            ]);
+        })
+        it("should suggest partial matches in expression", () => {
+            expect(cleanSuggestions(suggest("1 + C", expressionOpts))).toEqual([
+                { type: 'fields', text: 'C ' },
+            ]);
+        })
+    })
+
+    describe("compile() in syntax mode", () => {
+        it ("should parse source without whitespace into a recoverable syntax tree", () => {
+            const source = "1-Sum(A*2+\"Toucan Sam\")/Count()";
+            const tree = parse(source, aggregationOpts);
+            expect(serialize(tree)).toEqual(source)
+        })
+        xit ("should parse source with whitespace into a recoverable syntax tree", () => {
+            // FIXME: not preserving whitespace
+            const source = "1 - Sum(A * 2 + \"Toucan Sam\") / Count";
+            const tree = parse(source, aggregationOpts);
+            expect(serialize(tree)).toEqual(source)
+        })
+    })
+});
+
+function serialize(tree) {
+    if (tree.type === "token") {
+        return tree.text;
+    } else {
+        return tree.children.map(serialize).join("");
+    }
+}
+
+function cleanSuggestions(suggestions) {
+    return _.chain(suggestions).map(s => _.pick(s, "type", "text")).sortBy("text").sortBy("type").value();
+}
diff --git a/frontend/test/unit/lib/query.spec.js b/frontend/test/unit/lib/query.spec.js
index 158f950dc770e998db4b4369ff472ce7fa37c9f4..2f9aade317f18a2b2f388d67d4f2b02d5b4fe6f6 100644
--- a/frontend/test/unit/lib/query.spec.js
+++ b/frontend/test/unit/lib/query.spec.js
@@ -9,10 +9,7 @@ describe('Query', () => {
                 database: null,
                 type: "query",
                 query: {
-                    source_table: null,
-                    aggregation: ["rows"],
-                    breakout: [],
-                    filter: []
+                    source_table: null
                 }
             });
         });
@@ -28,52 +25,19 @@ describe('Query', () => {
         });
 
         it("should populate the databaseId if specified", () => {
-            expect(createQuery("query", 123)).toEqual({
-                database: 123,
-                type: "query",
-                query: {
-                    source_table: null,
-                    aggregation: ["rows"],
-                    breakout: [],
-                    filter: []
-                }
-            });
+            expect(createQuery("query", 123).database).toEqual(123);
         });
 
         it("should populate the tableId if specified", () => {
-            expect(createQuery("query", 123, 456)).toEqual({
-                database: 123,
-                type: "query",
-                query: {
-                    source_table: 456,
-                    aggregation: ["rows"],
-                    breakout: [],
-                    filter: []
-                }
-            });
+            expect(createQuery("query", 123, 456).query.source_table).toEqual(456);
         });
 
         it("should NOT set the tableId if query type is native", () => {
-            expect(createQuery("native", 123, 456)).toEqual({
-                database: 123,
-                type: "native",
-                native: {
-                    query: ""
-                }
-            });
+            expect(createQuery("native", 123, 456).query).toEqual(undefined);
         });
 
         it("should NOT populate the tableId if no database specified", () => {
-            expect(createQuery("query", null, 456)).toEqual({
-                database: null,
-                type: "query",
-                query: {
-                    source_table: null,
-                    aggregation: ["rows"],
-                    breakout: [],
-                    filter: []
-                }
-            });
+            expect(createQuery("query", null, 456).query.source_table).toEqual(null);
         });
     });
 
@@ -89,7 +53,7 @@ describe('Query', () => {
                 ]
             };
             Query.cleanQuery(query);
-            expect(JSON.stringify(query.order_by)).toBe(JSON.stringify([[1, "ascending"]]));
+            expect(query.order_by).toEqual([[1, "ascending"]]);
         });
         it('should remove incomplete sort clauses', () => {
             let query = {
@@ -116,7 +80,7 @@ describe('Query', () => {
                 ]
             };
             Query.cleanQuery(query);
-            expect(JSON.stringify(query.order_by)).toBe(JSON.stringify([[["aggregation", 0], "ascending"]]));
+            expect(query.order_by).toEqual([[["aggregation", 0], "ascending"]]);
         });
         it('should remove sort clauses on aggregations if that aggregation doesn\'t support it', () => {
             let query = {
@@ -143,7 +107,7 @@ describe('Query', () => {
                 ]
             };
             Query.cleanQuery(query);
-            expect(JSON.stringify(query.order_by)).toBe(JSON.stringify([[1, "ascending"]]));
+            expect(query.order_by).toEqual([[1, "ascending"]]);
         });
         it('should remove sort clauses on fields not appearing in breakout', () => {
             let query = {
@@ -170,7 +134,7 @@ describe('Query', () => {
                 ]
             };
             Query.cleanQuery(query);
-            expect(JSON.stringify(query.order_by)).toBe(JSON.stringify([[["fk->", 1, 2], "ascending"]]));
+            expect(query.order_by).toEqual([[["fk->", 1, 2], "ascending"]]);
         });
 
         it('should not remove sort clauses with datetime_fields on fields appearing in breakout', () => {
@@ -184,7 +148,7 @@ describe('Query', () => {
                 ]
             };
             Query.cleanQuery(query);
-            expect(JSON.stringify(query.order_by)).toBe(JSON.stringify([[["datetime_field", 1, "as", "week"], "ascending"]]));
+            expect(query.order_by).toEqual([[["datetime_field", 1, "as", "week"], "ascending"]]);
         });
 
         it('should replace order_by clauses with the exact matching datetime_fields version in the breakout', () => {
@@ -198,7 +162,7 @@ describe('Query', () => {
                 ]
             };
             Query.cleanQuery(query);
-            expect(JSON.stringify(query.order_by)).toBe(JSON.stringify([[["datetime_field", 1, "as", "week"], "ascending"]]));
+            expect(query.order_by).toEqual([[["datetime_field", 1, "as", "week"], "ascending"]]);
         });
 
         it('should replace order_by clauses with the exact matching fk-> version in the breakout', () => {
@@ -212,11 +176,11 @@ describe('Query', () => {
                 ]
             };
             Query.cleanQuery(query);
-            expect(JSON.stringify(query.order_by)).toBe(JSON.stringify([[["fk->", 1, 2], "ascending"]]));
+            expect(query.order_by).toEqual([[["fk->", 1, 2], "ascending"]]);
         });
     });
 
-    describe('removeDimension', () => {
+    describe('removeBreakout', () => {
         it('should remove the dimension', () => {
             let query = {
                 source_table: 0,
@@ -224,8 +188,8 @@ describe('Query', () => {
                 breakout: [1],
                 filter: []
             };
-            Query.removeDimension(query, 0);
-            expect(query.breakout.length).toBe(0);
+            Query.removeBreakout(query, 0);
+            expect(query.breakout).toBe(undefined);
         });
         it('should remove sort clauses for the dimension that was removed', () => {
             let query = {
@@ -237,7 +201,7 @@ describe('Query', () => {
                     [1, "ascending"]
                 ]
             };
-            Query.removeDimension(query, 0);
+            Query.removeBreakout(query, 0);
             expect(query.order_by).toBe(undefined);
         });
     });
diff --git a/frontend/test/unit/lib/query/query.spec.js b/frontend/test/unit/lib/query/query.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d24ce6ad1b42ca88ba970694fe430f52199f177
--- /dev/null
+++ b/frontend/test/unit/lib/query/query.spec.js
@@ -0,0 +1,73 @@
+import * as Query from "metabase/lib/query/query";
+
+describe("Query", () => {
+    describe("isBareRowsAggregation", () => {
+        it("should return true for all bare rows variation", () => {
+            expect(Query.isBareRows({})).toBe(true);
+            expect(Query.isBareRows({ "aggregation": null })).toBe(true);  // deprecated
+            expect(Query.isBareRows({ "aggregation": ["rows"] })).toBe(true); // deprecated
+            expect(Query.isBareRows({ "aggregation": [] })).toBe(true); // deprecated
+            expect(Query.isBareRows({ "aggregation": [["rows"]] })).toBe(true); // deprecated
+        })
+        it("should return false for other aggregations", () => {
+            expect(Query.isBareRows({ "aggregation": [["count"]] })).toBe(false);
+            expect(Query.isBareRows({ "aggregation": ["count"] })).toBe(false); // deprecated
+        })
+    })
+    describe("getAggregations", () => {
+        it("should return an empty list for bare rows", () => {
+            expect(Query.getAggregations({})).toEqual([]);
+            expect(Query.getAggregations({ "aggregation": [["rows"]] })).toEqual([]);
+            expect(Query.getAggregations({ "aggregation": ["rows"] })).toEqual([]); // deprecated
+        })
+        it("should return a single aggregation", () => {
+            expect(Query.getAggregations({ "aggregation": [["count"]] })).toEqual([["count"]]);
+            expect(Query.getAggregations({ "aggregation": ["count"] })).toEqual([["count"]]); // deprecated
+        })
+        it("should return multiple aggregations", () => {
+            expect(Query.getAggregations({ "aggregation": [["count"], ["sum", 1]] })).toEqual([["count"], ["sum", 1]]);
+        })
+    })
+    describe("addAggregation", () => {
+        it("should add one aggregation", () => {
+            expect(Query.addAggregation({}, ["count"])).toEqual({ aggregation: [["count"]] });
+        })
+        it("should add an aggregation to an existing one", () => {
+            expect(Query.addAggregation({ aggregation: [["count"]] }, ["sum", 1])).toEqual({ aggregation: [["count"], ["sum", 1]] });
+            // legacy
+            expect(Query.addAggregation({ aggregation: [["count"]] }, ["sum", 1])).toEqual({ aggregation: [["count"], ["sum", 1]] });
+        })
+    })
+    describe("updateAggregation", () => {
+        it("should update the correct aggregation", () => {
+            expect(Query.updateAggregation({ aggregation: [["count"], ["sum", 1]] }, 1, ["sum", 2])).toEqual({ aggregation: [["count"], ["sum", 2]] });
+        })
+    })
+    describe("removeAggregation", () => {
+        it("should remove one of two aggregations", () => {
+            expect(Query.removeAggregation({ aggregation: [["count"], ["sum", 1]] }, 0)).toEqual({ aggregation: [["sum", 1]] });
+        })
+        it("should remove the last aggregations", () => {
+            expect(Query.removeAggregation({ aggregation: [["count"]] }, 0)).toEqual({});
+            expect(Query.removeAggregation({ aggregation: ["count"] }, 0)).toEqual({}); // deprecated
+        })
+    })
+    describe("clearAggregations", () => {
+        it("should remove all aggregations", () => {
+            expect(Query.clearAggregations({ aggregation: [["count"]] })).toEqual({});
+            expect(Query.clearAggregations({ aggregation: [["count"], ["sum", 1]] })).toEqual({});
+            expect(Query.clearAggregations({ aggregation: ["count"] })).toEqual({}); // deprecated
+        })
+    })
+
+    describe("removeBreakout", () => {
+        it("should remove sort as well", () => {
+            expect(Query.removeBreakout({ breakout: [1], order_by: [[1, "ascending"]] }, 0)).toEqual({});
+            expect(Query.removeBreakout({ breakout: [2,1], order_by: [[1, "ascending"]] }, 0)).toEqual({ breakout: [1], order_by: [[1, "ascending"]] });
+        })
+        it("should not remove aggregation sorts", () => {
+            expect(Query.removeBreakout({ aggregation: [["count"]], breakout: [2,1], order_by: [[["aggregation", 0], "ascending"]] }, 0))
+                               .toEqual({ aggregation: [["count"]], breakout: [1], order_by: [[["aggregation", 0], "ascending"]] });
+        })
+    })
+})
diff --git a/frontend/test/unit/reference/utils.spec.js b/frontend/test/unit/reference/utils.spec.js
index 7c921aa12d0b5ba839a0d522e98027c7c246b976..ed576007bccc8269dfa1b6c922972d35fe9d8f5c 100644
--- a/frontend/test/unit/reference/utils.spec.js
+++ b/frontend/test/unit/reference/utils.spec.js
@@ -293,24 +293,33 @@ describe("Reference utils.js", () => {
             database = 1,
             table = 2,
             display = "table",
-            aggregation = [ "rows" ],
-            breakout = [],
-            filter = []
-        }) => ({
-            "name": null,
-            "display": display,
-            "visualization_settings": {},
-            "dataset_query": {
-                "database": database,
-                "type": "query",
-                "query": {
-                    "source_table": table,
-                    "aggregation": aggregation,
-                    "breakout": breakout,
-                    "filter": filter
+            aggregation,
+            breakout,
+            filter
+        }) => {
+            const card = {
+                "name": null,
+                "display": display,
+                "visualization_settings": {},
+                "dataset_query": {
+                    "database": database,
+                    "type": "query",
+                    "query": {
+                        "source_table": table
+                    }
                 }
+            };
+            if (aggregation != undefined) {
+                card.dataset_query.query.aggregation = aggregation;
             }
-        });
+            if (breakout != undefined) {
+                card.dataset_query.query.breakout = breakout;
+            }
+            if (filter != undefined) {
+                card.dataset_query.query.filter = filter;
+            }
+            return card;
+        };
 
         it("should generate correct question for table raw data", () => {
             const question = getQuestion({
diff --git a/package.json b/package.json
index c549711f9ddec6fc1e38496e0b1fc3deccce1083..6d7eea067d579cc7c1606ee0511f06a2cb214343 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
   "dependencies": {
     "ace-builds": "^1.2.2",
     "babel-polyfill": "^6.6.1",
+    "chevrotain": "^0.20.0",
     "classnames": "^2.1.3",
     "color": "^0.11.1",
     "crossfilter": "^1.3.12",
@@ -35,8 +36,11 @@
     "react-addons-perf": "^15.2.1",
     "react-addons-shallow-compare": "^15.2.1",
     "react-ansi-style": "^1.0.0",
+    "react-collapse": "^2.3.3",
     "react-dom": "^15.2.1",
     "react-draggable": "^2.2.3",
+    "react-height": "^2.1.1",
+    "react-motion": "^0.4.5",
     "react-redux": "^4.4.5",
     "react-resizable": "^1.0.1",
     "react-retina-image": "^2.0.0",
@@ -65,6 +69,7 @@
     "@kadira/storybook": "^1.16.0",
     "@slack/client": "^3.5.4",
     "babel-cli": "^6.11.4",
+    "babel-core": "^6.20.0",
     "babel-eslint": "^6.1.2",
     "babel-loader": "^6.2.4",
     "babel-plugin-transform-decorators-legacy": "^1.3.4",
@@ -78,12 +83,12 @@
     "eslint": "^3.5.0",
     "eslint-loader": "^1.6.0",
     "eslint-plugin-flowtype": "^2.22.0",
+    "eslint-plugin-jasmine": "^2.2.0",
     "eslint-plugin-react": "^6.3.0",
     "exports-loader": "^0.6.3",
     "extract-text-webpack-plugin": "^1.0.1",
     "file-loader": "^0.8.5",
     "flow-bin": "^0.32.0",
-    "flow-status-webpack-plugin": "^0.1.4",
     "fs-promise": "^0.5.0",
     "glob": "^5.0.15",
     "html-webpack-plugin": "^2.14.0",
@@ -123,7 +128,7 @@
   },
   "scripts": {
     "dev": "yarn && concurrently --kill-others -p name -n 'backend,frontend' -c 'blue,green' 'lein ring server' 'yarn run build-hot'",
-    "lint": "eslint --ext .js --ext .jsx --max-warnings 0 frontend/src",
+    "lint": "eslint --ext .js --ext .jsx --max-warnings 0 frontend/src frontend/test",
     "flow": "flow check",
     "test": "karma start frontend/test/karma.conf.js --single-run",
     "test-watch": "karma start frontend/test/karma.conf.js --auto-watch --reporters nyan",
diff --git a/project.clj b/project.clj
index a3aeb88bfab40ee54b4123b497e42118215a471b..5171a4ac0ae0ed816546394f18173d9a81644612 100644
--- a/project.clj
+++ b/project.clj
@@ -44,7 +44,7 @@
                   "v3-rev135-1.22.0"]
                  [com.google.apis/google-api-services-bigquery        ; Google BigQuery Java Client Library
                   "v2-rev324-1.22.0"]
-                 [com.h2database/h2 "1.4.192"]                        ; embedded SQL database
+                 [com.h2database/h2 "1.4.193"]                        ; embedded SQL database
                  [com.mattbertolini/liquibase-slf4j "2.0.0"]          ; Java Migrations lib
                  [com.mchange/c3p0 "0.9.5.2"]                         ; connection pooling library
                  [com.novemberain/monger "3.1.0"]                     ; MongoDB Driver
@@ -68,8 +68,7 @@
                  [org.yaml/snakeyaml "1.17"]                          ; YAML parser (required by liquibase)
                  [org.xerial/sqlite-jdbc "3.8.11.2"]                  ; SQLite driver !!! DO NOT UPGRADE THIS UNTIL UPSTREAM BUG IS FIXED -- SEE https://github.com/metabase/metabase/issues/3753 !!!
                  [postgresql "9.3-1102.jdbc41"]                       ; Postgres driver
-                 [io.crate/crate-jdbc "1.13.1"]                       ; Crate JDBC driver
-                 [io.crate/crate-client "0.56.0"]                     ; Crate Java client (used by Crate JDBC)
+                 [io.crate/crate-jdbc "2.1.2"]                        ; Crate JDBC driver
                  [prismatic/schema "1.1.3"]                           ; Data schema declaration and validation library
                  [ring/ring-jetty-adapter "1.5.0"]                    ; Ring adapter using Jetty webserver (used to run a Ring server for unit tests)
                  [ring/ring-json "0.4.0"]                             ; Ring middleware for reading/writing JSON automatically
diff --git a/resources/migrations/047_add_collection_table.yaml b/resources/migrations/047_add_collection_table.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..61d72ff455caca48be9c50d9f2f053aa378a1953
--- /dev/null
+++ b/resources/migrations/047_add_collection_table.yaml
@@ -0,0 +1,70 @@
+databaseChangeLog:
+  - changeSet:
+      id: 47
+      author: camsaul
+      changes:
+        ######################################## collection table ########################################
+        - createTable:
+            tableName: collection
+            remarks: 'Collections are an optional way to organize Cards and handle permissions for them.'
+            columns:
+              - column:
+                  name: id
+                  type: int
+                  autoIncrement: true
+                  constraints:
+                    primaryKey: true
+                    nullable: false
+              - column:
+                  name: name
+                  type: text
+                  remarks: 'The unique, user-facing name of this Collection.'
+                  constraints:
+                    nullable: false
+              - column:
+                  name: slug
+                  type: varchar(254)
+                  remarks: 'URL-friendly, sluggified, indexed version of name.'
+                  constraints:
+                    nullable: false
+                    unique: true
+              - column:
+                  name: description
+                  type: text
+                  remarks: 'Optional description for this Collection.'
+              - column:
+                  name: color
+                  type: char(7)
+                  remarks: 'Seven-character hex color for this Collection, including the preceding hash sign.'
+                  constraints:
+                    nullable: false
+              - column:
+                  name: archived
+                  type: boolean
+                  remarks: 'Whether this Collection has been archived and should be hidden from users.'
+                  defaultValueBoolean: false
+                  constraints:
+                    nullable: false
+        - createIndex:
+            tableName: collection
+            indexName: idx_collection_slug
+            columns:
+              column:
+                name: slug
+        ######################################## add collection_id to report_card ########################################
+        - addColumn:
+            tableName: report_card
+            columns:
+              - column:
+                  name: collection_id
+                  type: int
+                  remarks: 'Optional ID of Collection this Card belongs to.'
+                  constraints:
+                    references: collection(id)
+                    foreignKeyName: fk_card_collection_id
+        - createIndex:
+            tableName: report_card
+            indexName: idx_card_collection_id
+            columns:
+              column:
+                name: collection_id
diff --git a/resources/migrations/048_add_collection_revision_table.yaml b/resources/migrations/048_add_collection_revision_table.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..1be0134d81d42f154f8804a5a51f1fbaecc3db54
--- /dev/null
+++ b/resources/migrations/048_add_collection_revision_table.yaml
@@ -0,0 +1,46 @@
+databaseChangeLog:
+  - changeSet:
+      id: 48
+      author: camsaul
+      changes:
+        - createTable:
+            tableName: collection_revision
+            remarks: 'Used to keep track of changes made to collections.'
+            columns:
+              - column:
+                  name: id
+                  type: int
+                  autoIncrement: true
+                  constraints:
+                    primaryKey: true
+                    nullable: false
+              - column:
+                  name: before
+                  type: text
+                  remarks: 'Serialized JSON of the collections graph before the changes.'
+                  constraints:
+                    nullable: false
+              - column:
+                  name: after
+                  type: text
+                  remarks: 'Serialized JSON of the collections graph after the changes.'
+                  constraints:
+                    nullable: false
+              - column:
+                  name: user_id
+                  type: int
+                  remarks: 'The ID of the admin who made this set of changes.'
+                  constraints:
+                    nullable: false
+                    references: core_user(id)
+                    foreignKeyName: fk_collection_revision_user_id
+              - column:
+                  name: created_at
+                  type: datetime
+                  remarks: 'The timestamp of when these changes were made.'
+                  constraints:
+                    nullable: false
+              - column:
+                  name: remark
+                  type: text
+                  remarks: 'Optional remarks explaining why these changes were made.'
diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj
index 294ec2fe5387eb151d26bd8a95d06abfc6702d52..d205feba259d27fe23b66617360b47a355886154 100644
--- a/src/metabase/api/card.clj
+++ b/src/metabase/api/card.clj
@@ -4,13 +4,15 @@
             [compojure.core :refer [GET POST DELETE PUT]]
             [schema.core :as s]
             (metabase.api [common :refer :all]
-                          [dataset :as dataset-api])
+                          [dataset :as dataset-api]
+                          [label :as label-api])
             (metabase [db :as db]
                       [events :as events])
             (metabase.models [hydrate :refer [hydrate]]
                              [card :refer [Card], :as card]
                              [card-favorite :refer [CardFavorite]]
                              [card-label :refer [CardLabel]]
+                             [collection :refer [Collection]]
                              [common :as common]
                              [database :refer [Database]]
                              [interface :as models]
@@ -25,7 +27,7 @@
 
 ;;; ------------------------------------------------------------ Hydration ------------------------------------------------------------
 
-(defn- hydrate-labels
+(defn- ^:deprecated hydrate-labels
   "Efficiently hydrate the `Labels` for a large collection of `Cards`."
   [cards]
   (let [card-labels          (db/select [CardLabel :card_id :label_id])
@@ -50,12 +52,12 @@
 (defn- cards:all
   "Return all `Cards`."
   []
-  (db/select Card, :archived false, {:order-by [[:name :asc]]}))
+  (db/select Card, :archived false, {:order-by [[:%lower.name :asc]]}))
 
 (defn- cards:mine
   "Return all `Cards` created by current user."
   []
-  (db/select Card, :creator_id *current-user-id*, :archived false, {:order-by [[:name :asc]]}))
+  (db/select Card, :creator_id *current-user-id*, :archived false, {:order-by [[:%lower.name :asc]]}))
 
 (defn- cards:fav
   "Return all `Cards` favorited by the current user."
@@ -69,12 +71,12 @@
 (defn- cards:database
   "Return all `Cards` belonging to `Database` with DATABASE-ID."
   [database-id]
-  (db/select Card, :database_id database-id, :archived false, {:order-by [[:name :asc]]}))
+  (db/select Card, :database_id database-id, :archived false, {:order-by [[:%lower.name :asc]]}))
 
 (defn- cards:table
   "Return all `Cards` belonging to `Table` with TABLE-ID."
   [table-id]
-  (db/select Card, :table_id table-id, :archived false, {:order-by [[:name :asc]]}))
+  (db/select Card, :table_id table-id, :archived false, {:order-by [[:%lower.name :asc]]}))
 
 (defn- cards-with-ids
   "Return unarchived `Cards` with CARD-IDS.
@@ -106,34 +108,40 @@
 (defn- cards:archived
   "`Cards` that have been archived."
   []
-  (db/select Card, :archived true, {:order-by [[:name :asc]]}))
+  (db/select Card, :archived true, {:order-by [[:%lower.name :asc]]}))
 
 (def ^:private filter-option->fn
   "Functions that should be used to return cards for a given filter option. These functions are all be called with `model-id` as the sole paramenter;
    functions that don't use the param discard it via `u/drop-first-arg`.
 
      ((filter->option->fn :recent) model-id) -> (cards:recent)"
-  {:all      (u/drop-first-arg cards:all)
-   :mine     (u/drop-first-arg cards:mine)
-   :fav      (u/drop-first-arg cards:fav)
-   :database cards:database
-   :table    cards:table
-   :recent   (u/drop-first-arg cards:recent)
-   :popular  (u/drop-first-arg cards:popular)
-   :archived (u/drop-first-arg cards:archived)})
-
-(defn- card-has-label? [label-slug card]
+  {:all           (u/drop-first-arg cards:all)
+   :mine          (u/drop-first-arg cards:mine)
+   :fav           (u/drop-first-arg cards:fav)
+   :database      cards:database
+   :table         cards:table
+   :recent        (u/drop-first-arg cards:recent)
+   :popular       (u/drop-first-arg cards:popular)
+   :archived      (u/drop-first-arg cards:archived)})
+
+(defn- ^:deprecated card-has-label? [label-slug card]
   (contains? (set (map :slug (:labels card))) label-slug))
 
-(defn- cards-for-filter-option [filter-option model-id label]
+;; TODO - do we need to hydrate the cards' collections as well?
+(defn- cards-for-filter-option [filter-option model-id label collection]
   (let [cards (-> ((filter-option->fn (or filter-option :all)) model-id)
-                  (hydrate :creator)
+                  (hydrate :creator :collection)
                   hydrate-labels
                   hydrate-favorites)]
-    ;; Since labels are hydrated in Clojure-land we need to wait until this point to apply label filtering if applicable
-    (if-not (seq label)
-      cards
-      (filter (partial card-has-label? label) cards))))
+    ;; Since labels and collections are hydrated in Clojure-land we need to wait until this point to apply label/collection filtering if applicable
+    ;; COLLECTION can optionally be an empty string which is used to repre
+    (filter (cond
+              collection  (let [collection-id (when (seq collection)
+                                                (check-404 (db/select-one-id Collection :slug collection)))]
+                            (comp (partial = collection-id) :collection_id))
+              (seq label) (partial card-has-label? label)
+              :else       identity)
+            cards)))
 
 
 ;;; ------------------------------------------------------------ /api/card & /api/card/:id endpoints ------------------------------------------------------------
@@ -147,33 +155,47 @@
    but other options include `mine`, `fav`, `database`, `table`, `recent`, `popular`, and `archived`. See corresponding implementation
    functions above for the specific behavior of each filter option. :card_index:
 
-   Optionally filter cards by LABEL slug."
-  [f model_id label]
-  {f (s/maybe CardFilterOption), model_id (s/maybe su/IntGreaterThanZero), label (s/maybe su/NonBlankString)}
+   Optionally filter cards by LABEL or COLLECTION slug. (COLLECTION can be a blank string, to signify cards with *no collection* should be returned.)
+
+   NOTES:
+
+   *  Filtering by LABEL is considered *deprecated*, as `Labels` will be removed from an upcoming version of Metabase in favor of `Collections`.
+   *  LABEL and COLLECTION params are mutually exclusive; if both are specified, LABEL will be ignored and Cards will only be filtered by their `Collection`.
+   *  If no `Collection` exists with the slug COLLECTION, this endpoint will return a 404."
+  [f model_id label collection]
+  {f (s/maybe CardFilterOption), model_id (s/maybe su/IntGreaterThanZero), label (s/maybe su/NonBlankString), collection (s/maybe s/Str)}
   (let [f (keyword f)]
     (when (contains? #{:database :table} f)
       (checkp (integer? model_id) "id" (format "id is required parameter when filter mode is '%s'" (name f)))
       (case f
         :database (read-check Database model_id)
         :table    (read-check Database (db/select-one-field :db_id Table, :id model_id))))
-    (->> (cards-for-filter-option f model_id label)
+    (->> (cards-for-filter-option f model_id label collection)
          (filterv models/can-read?)))) ; filterv because we want make sure all the filtering is done while current user perms set is still bound
 
 
 (defendpoint POST "/"
   "Create a new `Card`."
-  [:as {{:keys [dataset_query description display name visualization_settings]} :body}]
+  [:as {{:keys [dataset_query description display name visualization_settings collection_id]} :body}]
   {name                   su/NonBlankString
+   description            (s/maybe su/NonBlankString)
    display                su/NonBlankString
-   visualization_settings su/Map}
+   visualization_settings su/Map
+   collection_id          (s/maybe su/IntGreaterThanZero)}
+  ;; check that we have permissions to run the query that we're trying to save
   (check-403 (perms/set-has-full-permissions-for-set? @*current-user-permissions-set* (card/query-perms-set dataset_query :write)))
+  ;; check that we have permissions for the collection we're trying to save this card to, if applicable
+  (when collection_id
+    (check-403 (perms/set-has-full-permissions? @*current-user-permissions-set* (perms/collection-readwrite-path collection_id))))
+  ;; everything is g2g, now save the card
   (->> (db/insert! Card
          :creator_id             *current-user-id*
          :dataset_query          dataset_query
          :description            description
          :display                display
          :name                   name
-         :visualization_settings visualization_settings)
+         :visualization_settings visualization_settings
+         :collection_id          collection_id)
        (events/publish-event! :card-create)))
 
 
@@ -181,34 +203,44 @@
   "Get `Card` with ID."
   [id]
   (-> (read-check Card id)
-      (hydrate :creator :dashboard_count :labels :can_write)
+      (hydrate :creator :dashboard_count :labels :can_write :collection)
       (assoc :actor_id *current-user-id*)
       (->> (events/publish-event! :card-read))
       (dissoc :actor_id)))
 
+(defn- check-permissions-for-collection
+  "Check that we have permissions to add or remove cards from `Collection` with COLLECTION-ID."
+  [collection-id]
+  (check-403 (perms/set-has-full-permissions? @*current-user-permissions-set* (perms/collection-readwrite-path collection-id))))
 
 (defendpoint PUT "/:id"
   "Update a `Card`."
-  [id :as {{:keys [dataset_query description display name visualization_settings archived], :as body} :body}]
+  [id :as {{:keys [dataset_query description display name visualization_settings archived collection_id], :as body} :body}]
   {name                   (s/maybe su/NonBlankString)
    display                (s/maybe su/NonBlankString)
+   description            (s/maybe su/NonBlankString)
    visualization_settings (s/maybe su/Map)
-   archived               (s/maybe s/Bool)}
+   archived               (s/maybe s/Bool)
+   collection_id          (s/maybe su/IntGreaterThanZero)}
   (let [card (write-check Card id)]
-    (db/update-non-nil-keys! Card id
-      :dataset_query          dataset_query
-      :description            description
-      :display                display
-      :name                   name
-      :visualization_settings visualization_settings
-      :archived               archived)
+    ;; if we're changing the `collection_id` of the Card, make sure we have write permissions for the new group
+    (when (and (not (nil? collection_id)) (not= (:collection_id card) collection_id))
+      (check-permissions-for-collection collection_id))
+    ;; ok, now save the Card
+    (db/update! Card id
+      (merge {:collection_id collection_id}
+             (when-not (nil? dataset_query)          {:dataset_query          dataset_query})
+             (when-not (nil? description)            {:description            description})
+             (when-not (nil? display)                {:display                display})
+             (when-not (nil? name)                   {:name                   name})
+             (when-not (nil? visualization_settings) {:visualization_settings visualization_settings})
+             (when-not (nil? archived)               {:archived               archived})))
     (let [event (cond
                   ;; card was archived
                   (and archived
                        (not (:archived card))) :card-archive
                   ;; card was unarchived
-                  (and (not (nil? archived))
-                       (not archived)
+                  (and (false? archived)
                        (:archived card))       :card-unarchive
                   :else                        :card-update)]
       (events/publish-event! event (assoc (Card id) :actor_id *current-user-id*)))))
@@ -243,9 +275,11 @@
 
 
 (defendpoint POST "/:card-id/labels"
-  "Update the set of `Labels` that apply to a `Card`."
+  "Update the set of `Labels` that apply to a `Card`.
+   (This endpoint is considered DEPRECATED as Labels will be removed in a future version of Metabase.)"
   [card-id :as {{:keys [label_ids]} :body}]
   {label_ids [su/IntGreaterThanZero]}
+  (label-api/warn-about-labels-being-deprecated)
   (write-check Card card-id)
   (let [[labels-to-remove labels-to-add] (data/diff (set (db/select-field :label_id CardLabel :card_id card-id))
                                                     (set label_ids))]
@@ -256,6 +290,38 @@
   {:status :ok})
 
 
+;;; ------------------------------------------------------------ Bulk Collections Update ------------------------------------------------------------
+
+(defn- move-cards-to-collection! [new-collection-id-or-nil card-ids]
+  ;; if moving to a collection, make sure we have write perms for it
+  (when new-collection-id-or-nil
+    (write-check Collection new-collection-id-or-nil))
+  ;; for each affected card...
+  (when (seq card-ids)
+    (let [cards (db/select [Card :id :collection_id :dataset_query]
+                  {:where [:and [:in :id (set card-ids)]
+                                [:or [:not= :collection_id new-collection-id-or-nil]
+                                     (when new-collection-id-or-nil
+                                       [:= :collection_id nil])]]})] ; poisioned NULLs = ick
+      ;; ...check that we have write permissions for it...
+      (doseq [card cards]
+        (write-check card))
+      ;; ...and check that we have write permissions for the old collections if applicable
+      (doseq [old-collection-id (set (filter identity (map :collection_id cards)))]
+        (write-check Collection old-collection-id)))
+    ;; ok, everything checks out. Set the new `collection_id` for all the Cards
+    (db/update-where! Card {:id [:in (set card-ids)]}
+      :collection_id new-collection-id-or-nil)))
+
+(defendpoint POST "/collections"
+  "Bulk update endpoint for Card Collections. Move a set of `Cards` with CARD_IDS into a `Collection` with COLLECTION_ID,
+   or remove them from any Collections by passing a `null` COLLECTION_ID."
+  [:as {{:keys [card_ids collection_id]} :body}]
+  {card_ids [su/IntGreaterThanZero], collection_id (s/maybe su/IntGreaterThanZero)}
+  (move-cards-to-collection! collection_id card_ids)
+  {:status :ok})
+
+
 ;;; ------------------------------------------------------------ Running a Query ------------------------------------------------------------
 
 (defn- run-query-for-card [card-id parameters]
diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj
new file mode 100644
index 0000000000000000000000000000000000000000..d637ba91260f3d83b7e63c4e3cc0fa51ad927d5b
--- /dev/null
+++ b/src/metabase/api/collection.clj
@@ -0,0 +1,92 @@
+(ns metabase.api.collection
+  "/api/collection endpoints."
+  (:require [compojure.core :refer [GET POST DELETE PUT]]
+            [schema.core :as s]
+            [metabase.api.common :as api]
+            [metabase.db :as db]
+            (metabase.models [card :refer [Card]]
+                             [collection :refer [Collection], :as collection]
+                             [hydrate :refer [hydrate]]
+                             [interface :as models])
+            [metabase.util.schema :as su]))
+
+
+(api/defendpoint GET "/"
+  "Fetch a list of all Collections that the current user has read permissions for.
+   This includes `:can_write`, which means whether the current user is allowed to add or remove Cards to this Collection; keep in mind
+   that regardless of this status you must be a superuser to modify properties of Collections themselves.
+
+   By default, this returns non-archived Collections, but instead you can show archived ones by passing `?archived=true`."
+  [archived]
+  {archived (s/maybe su/BooleanString)}
+  (-> (filterv models/can-read? (db/select Collection :archived (Boolean/parseBoolean archived) {:order-by [[:%lower.name :asc]]}))
+      (hydrate :can_write)))
+
+(api/defendpoint GET "/:id"
+  "Fetch a specific (non-archived) Collection, including cards that belong to it."
+  [id]
+  ;; TODO - hydrate the `:cards` that belong to this Collection
+  (assoc (api/read-check Collection id, :archived false)
+    :cards (db/select Card, :collection_id id, :archived false)))
+
+(api/defendpoint POST "/"
+  "Create a new Collection."
+  [:as {{:keys [name color description]} :body}]
+  {name su/NonBlankString, color collection/hex-color-regex, description (s/maybe su/NonBlankString)}
+  (api/check-superuser)
+  (db/insert! Collection
+    :name  name
+    :color color))
+
+(api/defendpoint PUT "/:id"
+  "Modify an existing Collection, including archiving or unarchiving it."
+  [id, :as {{:keys [name color description archived]} :body}]
+  {name su/NonBlankString, color collection/hex-color-regex, description (s/maybe su/NonBlankString), archived (s/maybe s/Bool)}
+  ;; you have to be a superuser to modify a Collection itself, but `/collection/:id/` perms are sufficient for adding/removing Cards
+  (api/check-superuser)
+  (api/check-exists? Collection id)
+  (db/update! Collection id
+    :name        name
+    :color       color
+    :description description
+    :archived    (if (nil? archived)
+                   false
+                   archived))
+  ;; return the updated object
+  (Collection id))
+
+
+;;; ------------------------------------------------------------ GRAPH ENDPOINTS ------------------------------------------------------------
+
+(api/defendpoint GET "/graph"
+  "Fetch a graph of all Collection Permissions."
+  []
+  (api/check-superuser)
+  (collection/graph))
+
+
+(defn- ->int [id] (Integer/parseInt (name id)))
+
+(defn- dejsonify-collections [collections]
+  (into {} (for [[collection-id perms] collections]
+             {(->int collection-id) (keyword perms)})))
+
+(defn- dejsonify-groups [groups]
+  (into {} (for [[group-id collections] groups]
+             {(->int group-id) (dejsonify-collections collections)})))
+
+(defn- dejsonify-graph
+  "Fix the types in the graph when it comes in from the API, e.g. converting things like `\"none\"` to `:none` and parsing object keys as integers."
+  [graph]
+  (update graph :groups dejsonify-groups))
+
+(api/defendpoint PUT "/graph"
+  "Do a batch update of Collections Permissions by passing in a modified graph."
+  [:as {body :body}]
+  {body su/Map}
+  (api/check-superuser)
+  (collection/update-graph! (dejsonify-graph body))
+  (collection/graph))
+
+
+(api/define-routes)
diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj
index a8f9dbe9efc75653d67489843df6de87c6c2789d..c6597276982e4f154398bd6bfd85e66f7336155d 100644
--- a/src/metabase/api/common.clj
+++ b/src/metabase/api/common.clj
@@ -266,20 +266,26 @@
   "Check whether we can read an existing OBJ, or ENTITY with ID.
    If the object doesn't exist, throw a 404; if we don't have proper permissions, throw a 403.
    This will fetch the object if it was not already fetched, and returns OBJ if the check is successful."
+  {:style/indent 2}
   ([obj]
    (check-404 obj)
    (check-403 (models/can-read? obj))
    obj)
   ([entity id]
-   (read-check (entity id))))
+   (read-check (entity id)))
+  ([entity id & other-conditions]
+   (read-check (apply db/select-one entity :id id other-conditions))))
 
 (defn write-check
   "Check whether we can write an existing OBJ, or ENTITY with ID.
    If the object doesn't exist, throw a 404; if we don't have proper permissions, throw a 403.
    This will fetch the object if it was not already fetched, and returns OBJ if the check is successful."
+  {:style/indent 2}
   ([obj]
    (check-404 obj)
    (check-403 (models/can-write? obj))
    obj)
   ([entity id]
-   (write-check (entity id))))
+   (write-check (entity id)))
+  ([entity id & other-conditions]
+   (write-check (apply db/select-one entity :id id other-conditions))))
diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj
index 8fb753b9bb5f69a415153cc28721e6882f9f5d79..60974c0d87a531be8cc5bfab51f4bf90ed8ef1ef 100644
--- a/src/metabase/api/dashboard.clj
+++ b/src/metabase/api/dashboard.clj
@@ -17,9 +17,10 @@
 
 
 (defn- dashboards-list [filter-option]
-  (filter models/can-read? (-> (db/select Dashboard {:where (case (or (keyword filter-option) :all)
-                                                              :all  true
-                                                              :mine [:= :creator_id *current-user-id*])})
+  (filter models/can-read? (-> (db/select Dashboard {:where    (case (or (keyword filter-option) :all)
+                                                                 :all  true
+                                                                 :mine [:= :creator_id *current-user-id*])
+                                                     :order-by [:%lower.name]})
                                (hydrate :creator))))
 
 (defendpoint GET "/"
diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj
index 382ef41080eb8163673d68a7780e9b75caea1cf2..ae5a30b845f7efed09b5a86c37109ebf10c49fde 100644
--- a/src/metabase/api/database.clj
+++ b/src/metabase/api/database.clj
@@ -39,7 +39,7 @@
 
 (defn- add-native-perms-info [dbs]
   (for [db dbs]
-    (let [user-has-perms? (fn [f] (perms/set-has-full-permissions? @*current-user-permissions-set* (f (u/get-id db))))]
+    (let [user-has-perms? (fn [path-fn] (perms/set-has-full-permissions? @*current-user-permissions-set* (path-fn (u/get-id db))))]
       (assoc db :native_permissions (cond
                                       (user-has-perms? perms/native-readwrite-path) :write
                                       (user-has-perms? perms/native-read-path)      :read
diff --git a/src/metabase/api/label.clj b/src/metabase/api/label.clj
index 020a6606273e26cdd3668291a7a82e698e13a187..15fce62dc95cdaf59a59af1b7c60168ee72ca940 100644
--- a/src/metabase/api/label.clj
+++ b/src/metabase/api/label.clj
@@ -1,37 +1,49 @@
-(ns metabase.api.label
+(ns ^:deprecated metabase.api.label
   "`/api/label` endpoints."
-  (:require [compojure.core :refer [GET POST DELETE PUT]]
+  (:require [clojure.tools.logging :as log]
+            [compojure.core :refer [GET POST DELETE PUT]]
             [schema.core :as s]
             [metabase.api.common :refer [defendpoint define-routes write-check]]
             [metabase.db :as db]
             [metabase.models.label :refer [Label]]
+            [metabase.util :as u]
             [metabase.util.schema :as su]))
 
+(defn warn-about-labels-being-deprecated
+  "Print a warning message about Labels-related endpoints being deprecated."
+  []
+  (log/warn (u/format-color 'yellow "Labels are deprecated, and this API endpoint will be removed in a future version of Metabase.")))
+
 (defendpoint GET "/"
-  "List all `Labels`. :label:"
+  "[DEPRECATED] List all `Labels`. :label:"
   []
+  (warn-about-labels-being-deprecated)
   (db/select Label {:order-by [:%lower.name]}))
 
 (defendpoint POST "/"
-  "Create a new `Label`. :label: "
+  "[DEPRECATED] Create a new `Label`. :label:"
   [:as {{:keys [name icon]} :body}]
   {name su/NonBlankString
    icon (s/maybe su/NonBlankString)}
+  (warn-about-labels-being-deprecated)
   (db/insert! Label, :name name, :icon icon))
 
 (defendpoint PUT "/:id"
-  "Update a `Label`. :label:"
+  "[DEPRECATED] Update a `Label`. :label:"
   [id :as {{:keys [name icon], :as body} :body}]
   {name (s/maybe su/NonBlankString)
    icon (s/maybe su/NonBlankString)}
+  (warn-about-labels-being-deprecated)
   (write-check Label id)
   (db/update! Label id body)
   (Label id)) ; return the updated Label
 
 (defendpoint DELETE "/:id"
-  "Delete a `Label`. :label:"
+  "[DEPRECATED] Delete a `Label`. :label:"
   [id]
+  (warn-about-labels-being-deprecated)
   (write-check Label id)
   (db/cascade-delete! Label :id id))
 
+
 (define-routes)
diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj
index 490d912390ee4b7b521c45a0e27d4aa7444a3795..2cfea6c9a14a3a78e515a2a28ee5c18162bfc29e 100644
--- a/src/metabase/api/routes.clj
+++ b/src/metabase/api/routes.clj
@@ -3,6 +3,7 @@
             [compojure.route :as route]
             (metabase.api [activity :as activity]
                           [card :as card]
+                          [collection :as collection]
                           [dashboard :as dashboard]
                           [database :as database]
                           [dataset :as dataset]
@@ -38,6 +39,7 @@
 (defroutes ^{:doc "Ring routes for API endpoints."} routes
   (context "/activity"        [] (+auth activity/routes))
   (context "/card"            [] (+auth card/routes))
+  (context "/collection"      [] (+auth collection/routes))
   (context "/dashboard"       [] (+auth dashboard/routes))
   (context "/database"        [] (+auth database/routes))
   (context "/dataset"         [] (+auth dataset/routes))
diff --git a/src/metabase/cmd/load_from_h2.clj b/src/metabase/cmd/load_from_h2.clj
index 72eb06212d58f93869c8c2e883bf7fb0564273b5..e3d90a1b48dfd2481d72d36f5eb6fb29d365bada 100644
--- a/src/metabase/cmd/load_from_h2.clj
+++ b/src/metabase/cmd/load_from_h2.clj
@@ -25,6 +25,8 @@
                              [card :refer [Card]]
                              [card-favorite :refer [CardFavorite]]
                              [card-label :refer [CardLabel]]
+                             [collection :refer [Collection]]
+                             [collection-revision :refer [CollectionRevision]]
                              [dashboard :refer [Dashboard]]
                              [dashboard-card :refer [DashboardCard]]
                              [dashboard-card-series :refer [DashboardCardSeries]]
@@ -97,6 +99,9 @@
    PermissionsGroupMembership
    Permissions
    PermissionsRevision
+   Collection
+   CollectionRevision
+   ;; migrate the list of finished DataMigrations as the very last thing (all models to copy over should be listed above this line)
    DataMigrations])
 
 
diff --git a/src/metabase/db/migrations.clj b/src/metabase/db/migrations.clj
index 6cbfd7a2bfa20e154f2eb155670e788e3286ab63..488babf9a0110f442f306b5c3368a95ac252992b 100644
--- a/src/metabase/db/migrations.clj
+++ b/src/metabase/db/migrations.clj
@@ -6,8 +6,9 @@
 
      CREATE TABLE IF NOT EXISTS ... -- Good
      CREATE TABLE ...               -- Bad"
-  (:require [clojure.string :as s]
+  (:require [clojure.string :as str]
             [clojure.tools.logging :as log]
+            [schema.core :as s]
             (metabase [config :as config]
                       [db :as db]
                       [driver :as driver]
@@ -15,10 +16,13 @@
             [metabase.events.activity-feed :refer [activity-feed-topics]]
             (metabase.models [activity :refer [Activity]]
                              [card :refer [Card]]
+                             [card-label :refer [CardLabel]]
+                             [collection :refer [Collection], :as collection]
                              [dashboard-card :refer [DashboardCard]]
                              [database :refer [Database]]
                              [field :refer [Field]]
                              [interface :refer [defentity]]
+                             [label :refer [Label]]
                              [permissions :refer [Permissions], :as perms]
                              [permissions-group :as perm-group]
                              [permissions-group-membership :refer [PermissionsGroupMembership], :as perm-membership]
@@ -297,14 +301,14 @@
 (defmigration ^{:author "camsaul", :added "0.20.0"} migrate-field-types
   (doseq [[old-type new-type] old-special-type->new-type]
     ;; migrate things like :timestamp_milliseconds -> :type/UNIXTimestampMilliseconds
-    (db/update-where! 'Field {:%lower.special_type (s/lower-case old-type)}
+    (db/update-where! 'Field {:%lower.special_type (str/lower-case old-type)}
       :special_type new-type)
     ;; migrate things like :UNIXTimestampMilliseconds -> :type/UNIXTimestampMilliseconds
     (db/update-where! 'Field {:special_type (name (keyword new-type))}
       :special_type new-type))
   (doseq [[old-type new-type] old-base-type->new-type]
     ;; migrate things like :DateTimeField -> :type/DateTime
-    (db/update-where! 'Field {:%lower.base_type (s/lower-case old-type)}
+    (db/update-where! 'Field {:%lower.base_type (str/lower-case old-type)}
       :base_type new-type)
     ;; migrate things like :DateTime -> :type/DateTime
     (db/update-where! 'Field {:base_type (name (keyword new-type))}
diff --git a/src/metabase/driver/bigquery.clj b/src/metabase/driver/bigquery.clj
index 5bb511027af735437e892f08232b86f5f7378260..abd938494cc07cdf4e40130149dc8b6cb295d88a 100644
--- a/src/metabase/driver/bigquery.clj
+++ b/src/metabase/driver/bigquery.clj
@@ -93,6 +93,8 @@
    "INTEGER"   :type/Integer
    "RECORD"    :type/Dictionary ; RECORD -> field has a nested schema
    "STRING"    :type/Text
+   "DATE"      :type/Date
+   "DATETIME"  :type/DateTime
    "TIMESTAMP" :type/DateTime})
 
 (defn- table-schema->metabase-field-info [^TableSchema schema]
@@ -106,7 +108,7 @@
    :fields (set (table-schema->metabase-field-info (.getSchema (get-table database table-name))))})
 
 
-(def ^:private ^:const query-timeout-seconds 60)
+(def ^:private ^:const ^Integer query-timeout-seconds 60)
 
 (defn- ^QueryResponse execute-bigquery
   ([{{:keys [project-id]} :details, :as database} query-string]
@@ -142,6 +144,8 @@
    "INTEGER"   #(Long/parseLong %)
    "RECORD"    identity
    "STRING"    identity
+   "DATE"      parse-timestamp-str
+   "DATETIME"  parse-timestamp-str
    "TIMESTAMP" parse-timestamp-str})
 
 (defn- post-process-native
@@ -236,6 +240,9 @@
     :quarter-of-year (hx/quarter expr)
     :year            (hx/year expr)))
 
+(defn- date-string->literal [^String date-string]
+  (hx/->timestamp (hx/literal (u/format-date "yyyy-MM-dd 00:00" (u/->Date date-string)))))
+
 (defn- unix-timestamp->timestamp [expr seconds-or-milliseconds]
   (case seconds-or-milliseconds
     :seconds      (hsql/call :sec_to_timestamp  expr)
@@ -326,6 +333,12 @@
                     ag-type)))
     :else (str schema-name \. table-name \. field-name)))
 
+;; TODO - Making 2 DB calls for each field to fetch its dataset is inefficient and makes me cry, but this method is currently only used for SQL params so it's not a huge deal at this point
+(defn- field->identifier [{table-id :table_id, :as field}]
+  (let [db-id   (db/select-one-field :db_id 'Table :id table-id)
+        dataset (:dataset-id (db/select-one-field :details Database, :id db-id))]
+    (hsql/raw (apply format "[%s.%s.%s]" dataset (field/qualified-name-components field)))))
+
 ;; We have to override the default SQL implementations of breakout and order-by because BigQuery propogates casting functions in SELECT
 ;; BAD:
 ;; SELECT msec_to_timestamp([sad_toucan_incidents.incidents.timestamp]) AS [sad_toucan_incidents.incidents.timestamp], count(*) AS [count]
@@ -381,9 +394,11 @@
           :connection-details->spec  (constantly nil)                           ; since we don't use JDBC
           :current-datetime-fn       (constantly :%current_timestamp)
           :date                      (u/drop-first-arg date)
+          :date-string->literal      (u/drop-first-arg date-string->literal)
           :field->alias              (u/drop-first-arg field->alias)
+          :field->identifier         (u/drop-first-arg field->identifier)
           :prepare-value             (u/drop-first-arg prepare-value)
-          :quote-style               (constantly :sqlserver)                    ; we want identifiers quoted [like].[this]
+          :quote-style               (constantly :sqlserver)                    ; we want identifiers quoted [like].[this] initially (we have to convert them to [like.this] before executing)
           :string-length-fn          (u/drop-first-arg string-length-fn)
           :unix-timestamp->timestamp (u/drop-first-arg unix-timestamp->timestamp)})
 
@@ -419,9 +434,15 @@
           ;; people can manually specifiy "foreign key" relationships in admin and everything should work correctly.
           ;; Since we can't infer any "FK" relationships during sync our normal FK tests are not appropriate for BigQuery, so they're disabled for the time being.
           ;; TODO - either write BigQuery-speciifc tests for FK functionality or add additional code to manually set up these FK relationships for FK tables
-          :features              (constantly (when-not config/is-test?
-                                               ;; during unit tests don't treat bigquery as having FK support
-                                               #{:foreign-keys}))
+          :features              (constantly (set/union #{:basic-aggregations
+                                                          :standard-deviation-aggregations
+                                                          :native-parameters
+                                                          ;; Expression aggregations *would* work, but BigQuery doesn't support the auto-generated column names. BQ column names
+                                                          ;; can only be alphanumeric or underscores. If we slugified the auto-generated column names, we could enable this feature.
+                                                          #_:expression-aggregations}
+                                                        (when-not config/is-test?
+                                                          ;; during unit tests don't treat bigquery as having FK support
+                                                          #{:foreign-keys})))
           :field-values-lazy-seq (u/drop-first-arg field-values-lazy-seq)
           :mbql->native          (u/drop-first-arg mbql->native)}))
 
diff --git a/src/metabase/driver/crate.clj b/src/metabase/driver/crate.clj
index 7ffad9abedbc368704f3412519372b5886785e3e..38417e30a249bb18b9e66b088e13e2a6e9750172 100644
--- a/src/metabase/driver/crate.clj
+++ b/src/metabase/driver/crate.clj
@@ -3,8 +3,7 @@
             [clojure.set :as set]
             [honeysql.core :as hsql]
             [metabase.driver :as driver]
-            (metabase.driver.crate [query-processor :as qp]
-                                   [util :as crate-util])
+            [metabase.driver.crate.util :as crate-util]
             [metabase.driver.generic-sql :as sql]
             [metabase.util :as u]))
 
@@ -44,16 +43,15 @@
 
 (defn- crate-spec
   [{:keys [hosts]
-    :or   {hosts "//localhost:4300"}
     :as   opts}]
   (merge {:classname   "io.crate.client.jdbc.CrateDriver" ; must be in classpath
           :subprotocol "crate"
-          :subname     (str hosts)}
+          :subname     (str "//" hosts "/")}
          (dissoc opts :hosts)))
 
 (defn- can-connect? [details]
   (let [connection-spec (crate-spec details)]
-    (= 1 (first (vals (first (jdbc/query connection-spec ["select 1 from sys.cluster"])))))))
+    (= 1 (first (vals (first (jdbc/query connection-spec ["select 1"])))))))
 
 (defn- string-length-fn [field-key]
   (hsql/call :char_length field-key))
@@ -70,14 +68,13 @@
           :date-interval  crate-util/date-interval
           :details-fields (constantly [{:name         "hosts"
                                         :display-name "Hosts"
-                                        :default      "//localhost:4300"}])
+                                        :default      "localhost:5432"}])
           :features       (comp (u/rpartial set/difference #{:foreign-keys}) sql/features)})
   sql/ISQLDriver
   (merge (sql/ISQLDriverDefaultsMixin)
          {:connection-details->spec  (u/drop-first-arg crate-spec)
           :column->base-type         (u/drop-first-arg column->base-type)
           :string-length-fn          (u/drop-first-arg string-length-fn)
-          :apply-filter              qp/apply-filter
           :date                      crate-util/date
           :unix-timestamp->timestamp crate-util/unix-timestamp->timestamp
           :current-datetime-fn       (constantly now)}))
diff --git a/src/metabase/driver/crate/query_processor.clj b/src/metabase/driver/crate/query_processor.clj
deleted file mode 100644
index 934141e7aaf4b4ccb88b03d95c970aa034578940..0000000000000000000000000000000000000000
--- a/src/metabase/driver/crate/query_processor.clj
+++ /dev/null
@@ -1,31 +0,0 @@
-(ns metabase.driver.crate.query-processor
-  (:require [honeysql.helpers :as h]
-            [metabase.driver.generic-sql.query-processor :as qp]
-            [metabase.query-processor.interface :as i]))
-
-(defn- rewrite-between
-  "Rewrite [:between <field> <min> <max>] -> [:and [:>= <field> <min>] [:<= <field> <max>]]"
-  [clause]
-  (i/strict-map->CompoundFilter {:compound-type :and
-                                 :subclauses    [(i/strict-map->ComparisonFilter {:filter-type :>=
-                                                                                  :field       (:field clause)
-                                                                                  :value       (:min-val clause)})
-                                                 (i/strict-map->ComparisonFilter {:filter-type :<=
-                                                                                  :field       (:field clause)
-                                                                                  :value       (:max-val clause)})]}))
-
-(defn- filter-clause->predicate
-  "resolve filters recursively"
-  [{:keys [compound-type filter-type subclause subclauses], :as clause}]
-  (case compound-type
-    :and (apply vector :and (map filter-clause->predicate subclauses))
-    :or  (apply vector :or  (map filter-clause->predicate subclauses))
-    :not [:not (filter-clause->predicate subclause)]
-    nil  (qp/filter-clause->predicate (if (= filter-type :between)
-                                        (rewrite-between clause)
-                                        clause))))
-
-(defn apply-filter
-  "Apply custom generic SQL filter. This is the place to perform query rewrites."
-  [_ honeysql-form {clause :filter}]
-  (h/where honeysql-form (filter-clause->predicate clause)))
diff --git a/src/metabase/driver/druid/query_processor.clj b/src/metabase/driver/druid/query_processor.clj
index abcbc0fbd723d2d0766f5677c01155fe542a3a0d..c617e1a7b7328c02ffba3c49237d8187a4ea2e4b 100644
--- a/src/metabase/driver/druid/query_processor.clj
+++ b/src/metabase/driver/druid/query_processor.clj
@@ -218,34 +218,35 @@
                                     (ag:count output-name))))
 
 
-(defn- handle-aggregation [query-type {ag-type :aggregation-type, ag-field :field, output-name :output-name, :as ag} druid-query]
-  (when (isa? query-type ::ag-query)
-    (merge-with concat
-      druid-query
-      (let [ag-type (when-not (= ag-type :rows) ag-type)]
-        (match [ag-type ag-field]
-          ;; For 'distinct values' queries (queries with a breakout by no aggregation) just aggregate by count, but name it :___count so it gets discarded automatically
-          [nil     nil] {:aggregations [(ag:count (or output-name :___count))]}
-
-          [:count  nil] {:aggregations [(ag:count (or output-name :count))]}
-
-          [:count    _] {:aggregations [(ag:count ag-field (or output-name :count))]}
-
-          [:avg      _] (let [count-name (name (gensym "___count_"))
-                              sum-name   (name (gensym "___sum_"))]
-                          {:aggregations     [(ag:count ag-field count-name)
-                                              (ag:doubleSum ag-field sum-name)]
-                           :postAggregations [{:type   :arithmetic
-                                               :name   (or output-name :avg)
-                                               :fn     :/
-                                               :fields [{:type :fieldAccess, :fieldName sum-name}
-                                                        {:type :fieldAccess, :fieldName count-name}]}]})
-          [:distinct _] {:aggregations [{:type       :cardinality
-                                         :name       (or output-name :distinct___count)
-                                         :fieldNames [(->rvalue ag-field)]}]}
-          [:sum      _] {:aggregations [(ag:doubleSum ag-field (or output-name :sum))]}
-          [:min      _] {:aggregations [(ag:doubleMin ag-field (or output-name :min))]}
-          [:max      _] {:aggregations [(ag:doubleMax ag-field (or output-name :max))]})))))
+(defn- handle-aggregation [query-type {ag-type :aggregation-type, ag-field :field, output-name :output-name, custom-name :custom-name, :as ag} druid-query]
+  (let [output-name (or custom-name output-name)]
+    (when (isa? query-type ::ag-query)
+      (merge-with concat
+        druid-query
+        (let [ag-type (when-not (= ag-type :rows) ag-type)]
+          (match [ag-type ag-field]
+            ;; For 'distinct values' queries (queries with a breakout by no aggregation) just aggregate by count, but name it :___count so it gets discarded automatically
+            [nil     nil] {:aggregations [(ag:count (or output-name :___count))]}
+
+            [:count  nil] {:aggregations [(ag:count (or output-name :count))]}
+
+            [:count    _] {:aggregations [(ag:count ag-field (or output-name :count))]}
+
+            [:avg      _] (let [count-name (name (gensym "___count_"))
+                                sum-name   (name (gensym "___sum_"))]
+                            {:aggregations     [(ag:count ag-field count-name)
+                                                (ag:doubleSum ag-field sum-name)]
+                             :postAggregations [{:type   :arithmetic
+                                                 :name   (or output-name :avg)
+                                                 :fn     :/
+                                                 :fields [{:type :fieldAccess, :fieldName sum-name}
+                                                          {:type :fieldAccess, :fieldName count-name}]}]})
+            [:distinct _] {:aggregations [{:type       :cardinality
+                                           :name       (or output-name :distinct___count)
+                                           :fieldNames [(->rvalue ag-field)]}]}
+            [:sum      _] {:aggregations [(ag:doubleSum ag-field (or output-name :sum))]}
+            [:min      _] {:aggregations [(ag:doubleMin ag-field (or output-name :min))]}
+            [:max      _] {:aggregations [(ag:doubleMax ag-field (or output-name :max))]}))))))
 
 (defn- add-expression-aggregation-output-names [args]
   (for [arg args]
diff --git a/src/metabase/driver/generic_sql.clj b/src/metabase/driver/generic_sql.clj
index 8d839133985c47758e1be29decd797b20294c3a5..d83b0869bf7ea6b914de8a880ba27875470ce5c8 100644
--- a/src/metabase/driver/generic_sql.clj
+++ b/src/metabase/driver/generic_sql.clj
@@ -19,6 +19,7 @@
            java.util.Map
            (clojure.lang Keyword PersistentVector)
            com.mchange.v2.c3p0.ComboPooledDataSource
+           metabase.models.field.FieldInstance
            (metabase.query_processor.interface Field Value)))
 
 (defprotocol ISQLDriver
@@ -58,9 +59,20 @@
   (date [this, ^Keyword unit, field-or-value]
     "Return a HoneySQL form for truncating a date or timestamp field or value to a given resolution, or extracting a date component.")
 
+  (date-string->literal [this, ^String date-string]
+    "*OPTIONAL*. Return an appropriate HoneySQL form to represent a DATE-STRING literal.
+     The default implementation is just `hx/literal`; in other words, it just single-quotes DATE-STRING. Some drivers like BigQuery or Oracle need to do something more advanced.
+     (This is used for the implementation of SQL parameters).")
+
   (excluded-schemas ^java.util.Set [this]
     "*OPTIONAL*. Set of string names of schemas to skip syncing tables from.")
 
+  (field->identifier [this, ^FieldInstance field]
+    "*OPTIONAL*. Return a HoneySQL form that should be used as the identifier for FIELD.
+     The default implementation returns a keyword generated by from the components returned by `field/qualified-name-components`.
+     Other drivers like BigQuery need to do additional qualification, e.g. the dataset name as well.
+     (At the time of this writing, this is only used by the SQL parameters implementation; in the future it will probably be used in more places as well.)")
+
   (field-percent-urls [this field]
     "*OPTIONAL*. Implementation of the `:field-percent-urls-fn` to be passed to `make-analyze-table`.
      The default implementation is `fast-field-percent-urls`, which avoids a full table scan. Substitue this with `slow-field-percent-urls` for databases
@@ -203,6 +215,7 @@
   ([table field]
    (hx/qualify-and-escape-dots (:schema table) (:name table) (:name field))))
 
+
 (defn- query
   "Execute a HONEYSQL-FROM query against DATABASE, DRIVER, and optionally TABLE."
   ([driver database honeysql-form]
@@ -412,7 +425,9 @@
    :apply-page           (resolve 'metabase.driver.generic-sql.query-processor/apply-page)
    :column->special-type (constantly nil)
    :current-datetime-fn  (constantly :%now)
+   :date-string->literal (u/drop-first-arg hx/literal)
    :excluded-schemas     (constantly nil)
+   :field->identifier    (u/drop-first-arg (comp (partial apply hsql/qualify) field/qualified-name-components))
    :field->alias         (u/drop-first-arg name)
    :field-percent-urls   fast-field-percent-urls
    :prepare-value        (u/drop-first-arg :value)
diff --git a/src/metabase/driver/generic_sql/query_processor.clj b/src/metabase/driver/generic_sql/query_processor.clj
index 98df73108f4d91546485229d74a43c1ed72f4522..7841d353e6e0ecec2ad6832e9cde59dd106094f0 100644
--- a/src/metabase/driver/generic_sql/query_processor.clj
+++ b/src/metabase/driver/generic_sql/query_processor.clj
@@ -153,24 +153,21 @@
   (h/merge-select honeysql-form [(expression-aggregation->honeysql driver expression)
                                  (hx/escape-dots (annotate/expression-aggregation-name expression))]))
 
+(defn- apply-single-aggregation [driver honeysql-form {:keys [aggregation-type field], :as aggregation}]
+  (h/merge-select honeysql-form [(aggregation->honeysql driver aggregation-type field)
+                                 (hx/escape-dots (annotate/expression-aggregation-name aggregation))]))
+
 (defn apply-aggregation
   "Apply a `aggregation` clauses to HONEYSQL-FORM. Default implementation of `apply-aggregation` for SQL drivers."
-  ([driver honeysql-form {aggregations :aggregation}]
-   (loop [form honeysql-form, [ag & more] aggregations]
-     (let [form (if (instance? Expression ag)
-                  (apply-expression-aggregation driver form ag)
-                  (let [{:keys [aggregation-type field]} ag]
-                    (apply-aggregation driver form aggregation-type field)))]
-       (if-not (seq more)
-         form
-         (recur form more)))))
-
-  ([driver honeysql-form aggregation-type field]
-   (h/merge-select honeysql-form [(aggregation->honeysql driver aggregation-type field)
-                                  ;; the column alias is always the same as the ag type except for `:distinct` with is called `:count` (WHY?)
-                                  (if (= aggregation-type :distinct)
-                                    :count
-                                    aggregation-type)])))
+  [driver honeysql-form {aggregations :aggregation}]
+  (loop [form honeysql-form, [ag & more] aggregations]
+    (let [form (if (instance? Expression ag)
+                 (apply-expression-aggregation driver form ag)
+                 (apply-single-aggregation driver form ag))]
+      (if-not (seq more)
+        form
+        (recur form more)))))
+
 
 (defn apply-breakout
   "Apply a `breakout` clause to HONEYSQL-FORM. Default implementation of `apply-breakout` for SQL drivers."
diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj
index fc56991cd440f41e5f17712be5fad543ebd148cc..9fbf8058bba7a5b14e1401c1e737cac76338a33f 100644
--- a/src/metabase/driver/h2.clj
+++ b/src/metabase/driver/h2.clj
@@ -210,7 +210,7 @@
          {:date-interval                     (u/drop-first-arg date-interval)
           :details-fields                    (constantly [{:name         "db"
                                                            :display-name "Connection String"
-                                                           :placeholder  "file:/Users/camsaul/bird_sightings/toucans;AUTO_SERVER=TRUE"
+                                                           :placeholder  "file:/Users/camsaul/bird_sightings/toucans"
                                                            :required     true}])
           :humanize-connection-error-message (u/drop-first-arg humanize-connection-error-message)
           :process-query-in-context          (u/drop-first-arg process-query-in-context)})
diff --git a/src/metabase/driver/mongo.clj b/src/metabase/driver/mongo.clj
index addce073530d27f4a23e4bb9f0291e937a87fe2d..6ebee0cef2d8340681fd80d1139fc3bc96dcadaf 100644
--- a/src/metabase/driver/mongo.clj
+++ b/src/metabase/driver/mongo.clj
@@ -192,6 +192,9 @@
                                                            :display-name "Database password"
                                                            :type         :password
                                                            :placeholder  "******"}
+                                                          {:name         "authdb"
+                                                           :display-name "Authentication Database"
+                                                           :placeholder  "Optional database to use when authenticating"}
                                                           {:name         "ssl"
                                                            :display-name "Use a secure connection (SSL)?"
                                                            :type         :boolean
diff --git a/src/metabase/driver/mongo/util.clj b/src/metabase/driver/mongo/util.clj
index b4dfcb5b5836c853c792124f7cf3bb303e8763d9..52e93eb088a8ac62476bae4d5fe998e8b6a0ea44 100644
--- a/src/metabase/driver/mongo/util.clj
+++ b/src/metabase/driver/mongo/util.clj
@@ -43,7 +43,7 @@
   "Run F with a new connection (bound to `*mongo-connection*`) to DATABASE.
    Don't use this directly; use `with-mongo-connection`."
   [f database]
-  (let [{:keys [dbname host port user pass ssl]
+  (let [{:keys [dbname host port user pass ssl authdb]
          :or   {port 27017, pass "", ssl false}} (cond
                                                    (string? database)            {:dbname database}
                                                    (:dbname (:details database)) (:details database) ; entire Database obj
@@ -53,9 +53,12 @@
                            user)
         pass             (when (seq pass)
                            pass)
+        authdb           (if (seq authdb)
+                           authdb
+                           dbname)
         server-address   (mg/server-address host port)
         credentials      (when user
-                           (mcred/create user dbname pass))
+                           (mcred/create user authdb pass))
         connect          (partial mg/connect server-address (build-connection-options :ssl? ssl))
         conn             (if credentials
                            (connect credentials)
diff --git a/src/metabase/driver/oracle.clj b/src/metabase/driver/oracle.clj
index 651827d0e5b3b286f3addf37070febce9a42bd78..add182523f6ac6db2453c204665e5e155bc88124 100644
--- a/src/metabase/driver/oracle.clj
+++ b/src/metabase/driver/oracle.clj
@@ -86,6 +86,11 @@
                            3)
     :year            (hsql/call :extract :year v)))
 
+(defn- date-string->literal [^String date-string]
+  (hsql/call :to_timestamp
+    (hx/literal (u/format-date "yyyy-MM-dd" (u/->Date date-string)))
+    (hx/literal "YYYY-MM-DD")))
+
 (def ^:private ^:const now             (hsql/raw "SYSDATE"))
 (def ^:private ^:const date-1970-01-01 (hsql/call :to_timestamp (hx/literal :1970-01-01) (hx/literal :YYYY-MM-DD)))
 
@@ -218,6 +223,7 @@
           :connection-details->spec  (u/drop-first-arg connection-details->spec)
           :current-datetime-fn       (constantly now)
           :date                      (u/drop-first-arg date)
+          :date-string->literal      (u/drop-first-arg date-string->literal)
           :excluded-schemas          (fn [& _]
                                        (set/union
                                         #{"ANONYMOUS"
diff --git a/src/metabase/email/messages.clj b/src/metabase/email/messages.clj
index ed83a1d7c40c7c44dc8f8f767ca5f3c3ed1b3720..922ea5c141e2d79a26b96caaec9a3f9e232bc65f 100644
--- a/src/metabase/email/messages.clj
+++ b/src/metabase/email/messages.clj
@@ -169,17 +169,21 @@
       (.write fos img-bytes))
     file))
 
+(defn- hash-bytes
+  "Generate a hash to be used in a Content-ID"
+  [^bytes img-bytes]
+  (Math/abs ^Integer (java.util.Arrays/hashCode img-bytes)))
+
 (defn- render-image [images-atom, ^bytes image-bytes]
-  (str "cid:IMAGE" (or (u/first-index-satisfying (fn [^bytes item]
-                                                   (java.util.Arrays/equals item image-bytes))
-                                                 @images-atom)
-                       (u/prog1 (count @images-atom)
-                         (swap! images-atom conj image-bytes)))))
+  (let [content-id (str (hash-bytes image-bytes) "@metabase")]
+    (if-not (contains? @images-atom content-id)
+      (swap! images-atom assoc content-id image-bytes))
+    (str "cid:" content-id)))
 
 (defn render-pulse-email
   "Take a pulse object and list of results, returns an array of attachment objects for an email"
   [pulse results]
-  (let [images       (atom [])
+  (let [images       (atom {})
         body         (binding [render/*include-title* true
                                render/*render-img-fn* (partial render-image images)]
                        (vec (cons :div (for [result results]
@@ -195,8 +199,8 @@
                         :quotationAuthor (:author data-quote)
                         :logoFooter      true})]
     (apply vector {:type "text/html; charset=utf-8" :content message-body}
-           (map-indexed (fn [idx bytes] {:type         :inline
-                                         :content-id   (str "IMAGE" idx)
-                                         :content-type "image/png"
-                                         :content      (write-byte-array-to-temp-file bytes)})
-                        @images))))
+           (map (fn [[content-id bytes]] {:type         :inline
+                                    :content-id   content-id
+                                    :content-type "image/png"
+                                    :content      (write-byte-array-to-temp-file bytes)})
+                (seq @images)))))
diff --git a/src/metabase/events/metabot_lifecycle.clj b/src/metabase/events/metabot_lifecycle.clj
index 1b95901f4c25139e6968730d6e6be83bf7656612..ca915a4900d69ef8ef0d88cf05356c4e2b947e9d 100644
--- a/src/metabase/events/metabot_lifecycle.clj
+++ b/src/metabase/events/metabot_lifecycle.clj
@@ -27,12 +27,12 @@
   (when-let [{topic :topic object :item} metabot-lifecycle-event]
     (try
       ;; if someone updated our slack-token, or metabot was enabled/disabled then react accordingly
-      (let [{:keys [slack-token metabot-enabled]} object]
-        (cond
-          (and (contains? object :metabot-enabled)
-               (not= "true" metabot-enabled))      (metabot/stop-metabot!)
-          (and (contains? object :slack-token)
-               (seq slack-token))                  (metabot/start-metabot!)))
+      (when (and (contains? object :metabot-enabled) (contains? object :slack-token))
+        (let [{:keys [slack-token metabot-enabled]} object]
+          (cond
+            (nil? slack-token)      (metabot/stop-metabot!)
+            (not metabot-enabled)   (metabot/stop-metabot!)
+            :else                   (metabot/restart-metabot!))))
       (catch Throwable e
         (log/warn (format "Failed to process driver notifications event. %s" topic) e)))))
 
diff --git a/src/metabase/metabot.clj b/src/metabase/metabot.clj
index a27038422976b9a834b86f5f472ee46ffa1cc2d3..29fe66769120886c9d7cc8895a2dadab26966898 100644
--- a/src/metabase/metabot.clj
+++ b/src/metabase/metabot.clj
@@ -317,3 +317,12 @@
   (log/info "Stopping MetaBot...  🤖")
   (reset! websocket-monitor-thread-id nil)
   (disconnect-websocket!))
+
+(defn restart-metabot!
+  "Restart the Metabot listening process.
+   Used on settings changed"
+  []
+  (when @websocket-monitor-thread-id
+    (log/info "Metabot already running. Killing the previous WebSocket listener first.")
+    (stop-metabot!))
+  (start-metabot!))
diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj
index 37b28772610cfbb15f0ca47f0d1c1af2de5e2028..4fba7f12fe350754121a8760ecde644cc78a38c6 100644
--- a/src/metabase/models/card.clj
+++ b/src/metabase/models/card.clj
@@ -2,9 +2,10 @@
   (:require [clojure.core.memoize :as memoize]
             [clojure.tools.logging :as log]
             [medley.core :as m]
-            [metabase.api.common :refer [*current-user-id* *current-user-permissions-set*]]
+            [metabase.api.common :refer [*current-user-id* *current-user-permissions-set*], :as api]
             [metabase.db :as db]
             (metabase.models [card-label :refer [CardLabel]]
+                             [collection :refer [Collection], :as collection]
                              [dependency :as dependency]
                              [interface :as i]
                              [label :refer [Label]]
@@ -75,8 +76,10 @@
   "Return a set of required permissions object paths for CARD.
    Optionally specify whether you want `:read` or `:write` permissions; default is `:read`.
    (`:write` permissions only affects native queries)."
-  [{query :dataset_query} read-or-write]
-  (query-perms-set query read-or-write))
+  [{query :dataset_query, collection-id :collection_id} read-or-write]
+  (if collection-id
+    (collection/perms-objects-set collection-id read-or-write)
+    (query-perms-set query read-or-write)))
 
 
 ;;; ------------------------------------------------------------ Dependencies ------------------------------------------------------------
@@ -117,13 +120,14 @@
 
 
 (defn- pre-insert [{:keys [dataset_query], :as card}]
+  ;; TODO - make sure if `collection_id` is specified that we have write permissions for tha tcollection
   (u/prog1 card
     ;; for native queries we need to make sure the user saving the card has native query permissions for the DB
     ;; because users can always see native Cards and we don't want someone getting around their lack of permissions that way
     (when (and *current-user-id*
                (= (keyword (:type dataset_query)) :native))
       (let [database (db/select-one ['Database :id :name], :id (:database dataset_query))]
-        (qp-perms/throw-if-cannot-run-new-native-query-referencing-db *current-user-id* database)))))
+        (qp-perms/throw-if-cannot-run-new-native-query-referencing-db database)))))
 
 (defn- pre-cascade-delete [{:keys [id]}]
   (db/cascade-delete! 'PulseCard :card_id id)
diff --git a/src/metabase/models/card_label.clj b/src/metabase/models/card_label.clj
index 929d0859b4564ac68f64a0501e3679b5134eb5cd..0588080d5e4e0532cc807a38c5344785aea5c4e1 100644
--- a/src/metabase/models/card_label.clj
+++ b/src/metabase/models/card_label.clj
@@ -1,8 +1,8 @@
-(ns metabase.models.card-label
+(ns ^:deprecated metabase.models.card-label
   (:require [metabase.models.interface :as i]
             [metabase.util :as u]))
 
-(i/defentity CardLabel :card_label)
+(i/defentity ^:deprecated CardLabel :card_label)
 
 (u/strict-extend (class CardLabel)
   i/IEntity
diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj
new file mode 100644
index 0000000000000000000000000000000000000000..0d38cf8abcaf0e1d2e77ccfd4fbf2887c437ecf3
--- /dev/null
+++ b/src/metabase/models/collection.clj
@@ -0,0 +1,177 @@
+(ns metabase.models.collection
+  (:require (clojure [data :as data]
+                     [string :as str])
+            [schema.core :as s]
+            [metabase.api.common :refer [*current-user-id*]]
+            [metabase.db :as db]
+            (metabase.models [collection-revision :refer [CollectionRevision], :as collection-revision]
+                             [interface :as i]
+                             [permissions :as perms])
+            [metabase.util :as u]
+            [metabase.util.schema :as su]))
+
+(def ^:private ^:const collection-slug-max-length
+  "Maximum number of characters allowed in a Collection `slug`."
+  254)
+
+(i/defentity Collection :collection)
+
+(defn- assert-unique-slug [slug]
+  (when (db/exists? Collection :slug slug)
+    (throw (ex-info "Name already taken"
+             {:status-code 400, :errors {:name "A collection with this name already exists"}}))))
+
+(def ^:const ^java.util.regex.Pattern hex-color-regex
+  "Regex for a valid value of `:color`, a 7-character hex string including the preceding hash sign."
+  #"^#[0-9A-Fa-f]{6}$")
+
+(defn- assert-valid-hex-color [^String hex-color]
+  (when (or (not (string? hex-color))
+            (not (re-matches hex-color-regex hex-color)))
+    (throw (ex-info "Invalid color"
+             {:status-code 400, :errors {:color "must be a valid 6-character hex color code"}}))))
+
+(defn- slugify [collection-name]
+  ;; double-check that someone isn't trying to use a blank string as the collection name
+  (when (str/blank? collection-name)
+    (throw (ex-info "Collection name cannot be blank!"
+             {:status-code 400, :errors {:name "cannot be blank"}})))
+  (u/slugify collection-name collection-slug-max-length))
+
+(defn- pre-insert [{collection-name :name, color :color, :as collection}]
+  (assert-valid-hex-color color)
+  (assoc collection :slug (u/prog1 (slugify collection-name)
+                            (assert-unique-slug <>))))
+
+(defn- pre-update [{collection-name :name, id :id, color :color, archived? :archived, :as collection}]
+  ;; make sure hex color is valid
+  (when (contains? collection :color)
+    (assert-valid-hex-color color))
+  ;; archive / unarchive cards in this collection as needed
+  (db/update-where! 'Card {:collection_id id}
+    :archived archived?)
+  ;; slugify the collection name and make sure it's unique
+  (if-not collection-name
+    collection
+    (assoc collection :slug (u/prog1 (slugify collection-name)
+                              (or (db/exists? Collection, :slug <>, :id id) ; if slug hasn't changed no need to check for uniqueness
+                                  (assert-unique-slug <>))))))              ; otherwise check to make sure the new slug is unique
+
+(defn- pre-cascade-delete [collection]
+  ;; unset the collection_id for Cards in this collection. This is mostly for the sake of tests since IRL we shouldn't be deleting collections, but rather archiving them instead
+  (db/update-where! 'Card {:collection_id (u/get-id collection)}
+    :collection_id nil))
+
+(defn perms-objects-set
+  "Return the required set of permissions to READ-OR-WRITE COLLECTION-OR-ID."
+  [collection-or-id read-or-write]
+  ;; This is not entirely accurate as you need to be a superuser to modifiy a collection itself (e.g., changing its name) but if you have write perms you can add/remove cards
+  #{(case read-or-write
+      :read  (perms/collection-read-path collection-or-id)
+      :write (perms/collection-readwrite-path collection-or-id))})
+
+
+(u/strict-extend (class Collection)
+  i/IEntity
+  (merge i/IEntityDefaults
+         {:hydration-keys     (constantly [:collection])
+          :types              (constantly {:name :clob, :description :clob})
+          :pre-insert         pre-insert
+          :pre-update         pre-update
+          :pre-cascade-delete pre-cascade-delete
+          :can-read?          (partial i/current-user-has-full-permissions? :read)
+          :can-write?         (partial i/current-user-has-full-permissions? :write)
+          :perms-objects-set  perms-objects-set}))
+
+
+;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
+;;; |                                                                       PERMISSIONS GRAPH                                                                        |
+;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
+
+;;; ---------------------------------------- Schemas ----------------------------------------
+
+(def ^:private CollectionPermissions
+  (s/enum :write :read :none))
+
+(def ^:private GroupPermissionsGraph
+  "collection-id -> status"
+  {su/IntGreaterThanZero CollectionPermissions})
+
+(def ^:private PermissionsGraph
+  {:revision s/Int
+   :groups   {su/IntGreaterThanZero GroupPermissionsGraph}})
+
+
+;;; ---------------------------------------- Fetch Graph ----------------------------------------
+
+(defn- group-id->permissions-set []
+  (into {} (for [[group-id perms] (group-by :group_id (db/select 'Permissions))]
+             {group-id (set (map :object perms))})))
+
+(s/defn ^:private ^:always-validate perms-type-for-collection :- CollectionPermissions
+  [permissions-set collection-id]
+  (cond
+    (perms/set-has-full-permissions? permissions-set (perms/collection-readwrite-path collection-id)) :write
+    (perms/set-has-full-permissions? permissions-set (perms/collection-read-path collection-id))      :read
+    :else                                                                                             :none))
+
+(s/defn ^:private ^:always-validate group-permissions-graph :- GroupPermissionsGraph
+  "Return the permissions graph for a single group having PERMISSIONS-SET."
+  [permissions-set collection-ids]
+  (into {} (for [collection-id collection-ids]
+             {collection-id (perms-type-for-collection permissions-set collection-id)})))
+
+(s/defn ^:always-validate graph :- PermissionsGraph
+  "Fetch a graph representing the current permissions status for every group and all permissioned collections.
+   This works just like the function of the same name in `metabase.models.permissions`; see also the documentation for that function."
+  []
+  (let [group-id->perms (group-id->permissions-set)
+        collection-ids  (db/select-ids 'Collection)]
+    {:revision (collection-revision/latest-id)
+     :groups   (into {} (for [group-id (db/select-ids 'PermissionsGroup)]
+                          {group-id (group-permissions-graph (group-id->perms group-id) collection-ids)}))}))
+
+
+;;; ---------------------------------------- Update Graph ----------------------------------------
+
+(s/defn ^:private ^:always-validate update-collection-permissions! [group-id :- su/IntGreaterThanZero, collection-id :- su/IntGreaterThanZero, new-collection-perms :- CollectionPermissions]
+  ;; remove whatever entry is already there (if any) and add a new entry if applicable
+  (perms/revoke-collection-permissions! group-id collection-id)
+  (case new-collection-perms
+    :write (perms/grant-collection-readwrite-permissions! group-id collection-id)
+    :read  (perms/grant-collection-read-permissions! group-id collection-id)
+    :none  nil))
+
+(s/defn ^:private ^:always-validate update-group-permissions! [group-id :- su/IntGreaterThanZero, new-group-perms :- GroupPermissionsGraph]
+  (doseq [[collection-id new-perms] new-group-perms]
+    (update-collection-permissions! group-id collection-id new-perms)))
+
+(defn- save-perms-revision!
+  "Save changes made to the collection permissions graph for logging/auditing purposes.
+   This doesn't do anything if `*current-user-id*` is unset (e.g. for testing or REPL usage)."
+  [current-revision old new]
+  (when *current-user-id*
+    (db/insert! CollectionRevision
+      :id     (inc current-revision) ; manually specify ID here so if one was somehow inserted in the meantime in the fraction of a second
+      :before  old                   ; since we called `check-revision-numbers` the PK constraint will fail and the transaction will abort
+      :after   new
+      :user_id *current-user-id*)))
+
+(s/defn ^:always-validate update-graph!
+  "Update the collections permissions graph.
+   This works just like the function of the same name in `metabase.models.permissions`, but for `Collections`;
+   refer to that function's extensive documentation to get a sense for how this works."
+  ([new-graph :- PermissionsGraph]
+   (let [old-graph (graph)
+         [old new] (data/diff (:groups old-graph) (:groups new-graph))]
+     (perms/log-permissions-changes old new)
+     (perms/check-revision-numbers old-graph new-graph)
+     (when (seq new)
+       (db/transaction
+         (doseq [[group-id changes] new]
+           (update-group-permissions! group-id changes))
+         (save-perms-revision! (:revision old-graph) old new)))))
+  ;; The following arity is provided soley for convenience for tests/REPL usage
+  ([ks new-value]
+   {:pre [(sequential? ks)]}
+   (update-graph! (assoc-in (graph) (cons :groups ks) new-value))))
diff --git a/src/metabase/models/collection_revision.clj b/src/metabase/models/collection_revision.clj
new file mode 100644
index 0000000000000000000000000000000000000000..ed44b94d0d23cf5f6385be53b66f9d7e0bc23058
--- /dev/null
+++ b/src/metabase/models/collection_revision.clj
@@ -0,0 +1,26 @@
+(ns metabase.models.collection-revision
+  (:require [metabase.db :as db]
+            [metabase.models.interface :as i]
+            [metabase.util :as u]))
+
+(i/defentity CollectionRevision :collection_revision)
+
+(defn- pre-insert [revision]
+  (assoc revision :created_at (u/new-sql-timestamp)))
+
+(u/strict-extend (class CollectionRevision)
+  i/IEntity
+  (merge i/IEntityDefaults
+         {:types      (constantly {:before :json
+                                   :after  :json
+                                   :remark :clob})
+          :pre-insert pre-insert
+          :pre-update (fn [& _] (throw (Exception. "You cannot update a CollectionRevision!")))}))
+
+
+(defn latest-id
+  "Return the ID of the newest `CollectionRevision`, or zero if none have been made yet.
+   (This is used by the collection graph update logic that checks for changes since the original graph was fetched)."
+  []
+  (or (db/select-one-id CollectionRevision {:order-by [[:id :desc]]})
+      0))
diff --git a/src/metabase/models/field.clj b/src/metabase/models/field.clj
index 1a4e27f507f74017fb6967f12379d644f3c99d93..4d3830a35b81d193715e7d9e60ae26dd38aa09de 100644
--- a/src/metabase/models/field.clj
+++ b/src/metabase/models/field.clj
@@ -106,7 +106,7 @@
                                                (:fk_target_field_id field))]
                                 (:fk_target_field_id field)))
         id->target-field (u/key-by :id (when (seq target-field-ids)
-                                         (db/select Field :id [:in target-field-ids])))]
+                                         (filter i/can-read? (db/select Field :id [:in target-field-ids]))))]
     (for [field fields
           :let  [target-id (:fk_target_field_id field)]]
       (assoc field :target (id->target-field target-id)))))
diff --git a/src/metabase/models/label.clj b/src/metabase/models/label.clj
index 1b327f120b22f0d3d02051afb5a5c8f2991dbd31..33278c84bf664a39be49ec2a8ba0b16e9fd57126 100644
--- a/src/metabase/models/label.clj
+++ b/src/metabase/models/label.clj
@@ -1,13 +1,15 @@
-(ns metabase.models.label
+(ns ^:deprecated metabase.models.label
+  "Labels that can be applied to Cards. Deprecated in favor of Collections."
   (:require [metabase.db :as db]
             [metabase.models.interface :as i]
             [metabase.util :as u]))
 
-(i/defentity Label :label)
+(i/defentity ^:deprecated Label :label)
 
 (defn- assert-unique-slug [slug]
   (when (db/exists? Label :slug slug)
-    (throw (ex-info "Name already taken" {:status-code 400, :errors {:name "A label with this name already exists"}}))))
+    (throw (ex-info "Name already taken"
+             {:status-code 400, :errors {:name "A label with this name already exists"}}))))
 
 (defn- pre-insert [{label-name :name, :as label}]
   (assoc label :slug (u/prog1 (u/slugify label-name)
@@ -20,8 +22,8 @@
                          (or (db/exists? Label, :slug <>, :id id) ; if slug hasn't changed no need to check for uniqueness
                              (assert-unique-slug <>))))))         ; otherwise check to make sure the new slug is unique
 
-(defn- pre-cascade-delete [{:keys [id]}]
-  (db/cascade-delete! 'CardLabel :label_id id))
+(defn- pre-cascade-delete [label]
+  (db/cascade-delete! 'CardLabel :label_id (u/get-id label)))
 
 (u/strict-extend (class Label)
   i/IEntity
diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj
index b30d433f3b2432a3349dc4c7244b72a7358bc392..107d0f763153bb7ca1b7cd407d0f9bef63778802 100644
--- a/src/metabase/models/permissions.clj
+++ b/src/metabase/models/permissions.clj
@@ -11,7 +11,8 @@
                              [permissions-group :as group]
                              [permissions-revision :refer [PermissionsRevision] :as perms-revision])
             [metabase.util :as u]
-            [metabase.util.honeysql-extensions :as hx]))
+            (metabase.util [honeysql-extensions :as hx]
+                           [schema :as su])))
 
 
 ;;; +------------------------------------------------------------------------------------------------------------------------------------------------------+
@@ -35,10 +36,12 @@
 (def ^:private ^:const valid-object-path-patterns
   [#"^/db/(\d+)/$"                                ; permissions for the entire DB -- native and all schemas
    #"^/db/(\d+)/native/$"                         ; permissions to create new native queries for the DB
-   #"^/db/(\d+)/native/read/$"                    ; permissions to read the results of existing native queries (i.e. view existing cards) for the DB
+   #"^/db/(\d+)/native/read/$"                    ; (DEPRECATED) permissions to read the results of existing native queries (i.e. view existing cards) for the DB
    #"^/db/(\d+)/schema/$"                         ; permissions for all schemas in the DB
    #"^/db/(\d+)/schema/([^\\/]*)/$"               ; permissions for a specific schema
-   #"^/db/(\d+)/schema/([^\\/]*)/table/(\d+)/$"]) ; permissions for a specific table
+   #"^/db/(\d+)/schema/([^\\/]*)/table/(\d+)/$"   ; permissions for a specific table
+   #"^/collection/(\d+)/$"                        ; readwrite permissions for a collection
+   #"^/collection/(\d+)/read/$"])                 ; read permissions for a collection
 
 (defn valid-object-path?
   "Does OBJECT-PATH follow a known, allowed format to an *object*?
@@ -88,9 +91,10 @@
   ^String [database-id]
   (str (object-path database-id) "native/"))
 
-(defn native-read-path
+(defn ^:deprecated native-read-path
   "Return the native query *read* permissions path for a database.
-   This grants you permissions to view the results of an *existing* native query, i.e. view native Cards created by others."
+   This grants you permissions to view the results of an *existing* native query, i.e. view native Cards created by others.
+   (Deprecated because native read permissions are being phased out in favor of Collections.)"
   ^String [database-id]
   (str (object-path database-id) "native/read/"))
 
@@ -99,19 +103,29 @@
   ^String [database-id]
   (str (object-path database-id) "schema/"))
 
+(defn collection-read-path
+  "Return the permissions path for *read* access for a COLLECTION-OR-ID."
+  ^String [collection-or-id]
+  (str "/collection/" (u/get-id collection-or-id) "/read/"))
+
+(defn collection-readwrite-path
+  "Return the permissions path for *readwrite* access for a COLLECTION-OR-ID."
+  ^String [collection-or-id]
+  (str "/collection/" (u/get-id collection-or-id) "/"))
+
 
 ;;; ---------------------------------------- Permissions Checking Fns ----------------------------------------
 
 (defn is-permissions-for-object?
-  "Does PERMISSIONS-PATH grant *full* access for object PATH?"
-  [permissions-path path]
-  (str/starts-with? path permissions-path))
+  "Does PERMISSIONS-PATH grant *full* access for OBJECT-PATH?"
+  [permissions-path object-path]
+  (str/starts-with? object-path permissions-path))
 
 (defn is-partial-permissions-for-object?
-  "Does PERMISSIONS-PATH grant access full access for OBJECT path *or* for a descendant of object PATH?"
-  [permissions-path path]
-  (or (is-permissions-for-object? permissions-path path)
-      (str/starts-with? permissions-path path)))
+  "Does PERMISSIONS-PATH grant access full access for OBJECT-PATH *or* for a descendant of OBJECT-PATH?"
+  [permissions-path object-path]
+  (or (is-permissions-for-object? permissions-path object-path)
+      (str/starts-with? permissions-path object-path)))
 
 
 (defn is-permissions-set?
@@ -160,13 +174,13 @@
 (defn- pre-insert [permissions]
   (u/prog1 permissions
     (assert-valid permissions)
-    #_(log/info (u/format-color 'green "Granting permissions for group %d: %s" (:group_id permissions) (:object permissions)))))
+    (log/debug (u/format-color 'green "Granting permissions for group %d: %s" (:group_id permissions) (:object permissions)))))
 
 (defn- pre-update [_]
   (throw (Exception. "You cannot update a permissions entry! Delete it and create a new one.")))
 
 (defn- pre-cascade-delete [permissions]
-  #_(log/info (u/format-color 'red "Revoking permissions for group %d: %s" (:group_id permissions) (:object permissions)))
+  (log/debug (u/format-color 'red "Revoking permissions for group %d: %s" (:group_id permissions) (:object permissions)))
   (assert-not-admin-group permissions))
 
 
@@ -186,10 +200,10 @@
 
 (def ^:private SchemaPermissionsGraph
   (s/cond-pre (s/enum :none :all)
-              {s/Int TablePermissionsGraph}))
+              {su/IntGreaterThanZero TablePermissionsGraph}))
 
 (def ^:private NativePermissionsGraph
-  (s/enum :write :read :none))
+  (s/enum :write :read :none)) ; :read is DEPRECATED
 
 (def ^:private DBPermissionsGraph
   {(s/optional-key :native)  NativePermissionsGraph
@@ -197,11 +211,11 @@
                                          {(s/maybe s/Str) SchemaPermissionsGraph})})
 
 (def ^:private GroupPermissionsGraph
-  {s/Int DBPermissionsGraph})
+  {su/IntGreaterThanZero DBPermissionsGraph})
 
 (def ^:private PermissionsGraph
   {:revision s/Int
-   :groups   {s/Int GroupPermissionsGraph}})
+   :groups   {su/IntGreaterThanZero GroupPermissionsGraph}})
 
 ;; The "Strict" versions of the various graphs below are intended for schema checking when *updating* the permissions graph.
 ;; In other words, we shouldn't be stopped from returning the graph if it violates the "strict" rules, but we *should* refuse to update the
@@ -224,11 +238,11 @@
                  "DB permissions with a valid combination of values for :native and :schemas"))
 
 (def ^:private StrictGroupPermissionsGraph
-  {s/Int StrictDBPermissionsGraph})
+  {su/IntGreaterThanZero StrictDBPermissionsGraph})
 
 (def ^:private StrictPermissionsGraph
   {:revision s/Int
-   :groups   {s/Int StrictGroupPermissionsGraph}})
+   :groups   {su/IntGreaterThanZero StrictGroupPermissionsGraph}})
 
 
 ;;; +------------------------------------------------------------------------------------------------------------------------------------------------------+
@@ -273,7 +287,7 @@
 
 ;; TODO - if a DB has no tables, then it won't show up in the permissions graph!
 (s/defn ^:always-validate graph :- PermissionsGraph
-  "Fetch a graph representing the current permissions status for every group and all permissioned objects."
+  "Fetch a graph representing the current permissions status for every group and all permissioned databases."
   []
   (let [permissions (db/select [Permissions :group_id :object])
         tables      (group-by :db_id (db/select ['Table :schema :id :db_id]))]
@@ -311,7 +325,7 @@
       (db/cascade-delete! Permissions where))))
 
 (defn revoke-permissions!
-  "Revoke permissions for GROUP-OR-ID to object with PATH-COMPONENTS."
+  "Revoke all permissions for GROUP-OR-ID to object with PATH-COMPONENTS, *including* related permissions."
   [group-or-id & path-components]
   (delete-related-permissions! group-or-id (apply object-path path-components)))
 
@@ -334,8 +348,9 @@
   [group-or-id database-id]
   (delete-related-permissions! group-or-id (native-readwrite-path database-id)))
 
-(defn grant-native-read-permissions!
-  "Grant native *read* permissions for GROUP-OR-ID for database with DATABASE-ID."
+(defn ^:deprecated grant-native-read-permissions!
+  "Grant native *read* permissions for GROUP-OR-ID for database with DATABASE-ID.
+   (Deprecated because native read permissions are being phased out in favor of Card Collections.)"
   [group-or-id database-id]
   (grant-permissions! group-or-id (native-read-path database-id)))
 
@@ -345,7 +360,7 @@
   (grant-permissions! group-or-id (native-readwrite-path database-id)))
 
 (defn revoke-db-schema-permissions!
-  "Remove all permissions entires for a DB and any child objects.
+  "Remove all permissions entires for a DB and *any* child objects.
    This does *not* revoke native permissions; use `revoke-native-permssions!` to do that."
   [group-or-id database-id]
   ;; TODO - if permissions for this DB are DB root entries like `/db/1/` won't this end up removing our native perms?
@@ -366,23 +381,38 @@
   {:pre [(integer? group-id) (integer? database-id)]}
   (grant-permissions! group-id (object-path database-id)))
 
+(defn revoke-collection-permissions!
+  "Revoke all access for GROUP-OR-ID to a Collection."
+  [group-or-id collection-or-id]
+  (delete-related-permissions! group-or-id (collection-readwrite-path collection-or-id)))
+
+(defn grant-collection-readwrite-permissions!
+  "Grant full access to a Collection, which means a user can view all Cards in the Collection and add/remove Cards."
+  [group-or-id collection-or-id]
+  (grant-permissions! (u/get-id group-or-id) (collection-readwrite-path collection-or-id)))
+
+(defn grant-collection-read-permissions!
+  "Grant read access to a Collection, which means a user can view all Cards in the Collection."
+  [group-or-id collection-or-id]
+  (grant-permissions! (u/get-id group-or-id) (collection-read-path collection-or-id)))
+
 
 ;;; ---------------------------------------- Graph Updating Fns ----------------------------------------
 
-(s/defn ^:private ^:always-validate update-table-perms! [group-id :- s/Int, db-id :- s/Int, schema :- s/Str, table-id :- s/Int, new-table-perms :- SchemaPermissionsGraph]
+(s/defn ^:private ^:always-validate update-table-perms! [group-id :- su/IntGreaterThanZero, db-id :- su/IntGreaterThanZero, schema :- s/Str, table-id :- su/IntGreaterThanZero, new-table-perms :- SchemaPermissionsGraph]
   (case new-table-perms
     :all  (grant-permissions! group-id db-id schema table-id)
     :none (revoke-permissions! group-id db-id schema table-id)))
 
-(s/defn ^:private ^:always-validate update-schema-perms! [group-id :- s/Int, db-id :- s/Int, schema :- s/Str, new-schema-perms :- SchemaPermissionsGraph]
-  (revoke-permissions! group-id db-id schema)
+(s/defn ^:private ^:always-validate update-schema-perms! [group-id :- su/IntGreaterThanZero, db-id :- su/IntGreaterThanZero, schema :- s/Str, new-schema-perms :- SchemaPermissionsGraph]
   (cond
-    (= new-schema-perms :all)  (grant-permissions! group-id db-id schema)
-    (= new-schema-perms :none) nil
+    (= new-schema-perms :all)  (do (revoke-permissions! group-id db-id schema) ; clear out any existing related permissions
+                                   (grant-permissions! group-id db-id schema)) ; then grant full perms for the schema
+    (= new-schema-perms :none) (revoke-permissions! group-id db-id schema)
     (map? new-schema-perms)    (doseq [[table-id table-perms] new-schema-perms]
                                  (update-table-perms! group-id db-id schema table-id table-perms))))
 
-(s/defn ^:private ^:always-validate update-native-permissions! [group-id :- s/Int, db-id :- s/Int, new-native-perms :- NativePermissionsGraph]
+(s/defn ^:private ^:always-validate update-native-permissions! [group-id :- su/IntGreaterThanZero, db-id :- su/IntGreaterThanZero, new-native-perms :- NativePermissionsGraph]
   ;; revoke-native-permissions! will delete all entires that would give permissions for native access.
   ;; Thus if you had a root DB entry like `/db/11/` this will delete that too.
   ;; In that case we want to create a new full schemas entry so you don't lose access to all schemas when we modify native access.
@@ -396,23 +426,23 @@
     :none  nil))
 
 
-(s/defn ^:private ^:always-validate update-db-permissions! [group-id :- s/Int, db-id :- s/Int, new-db-perms :- StrictDBPermissionsGraph]
+(s/defn ^:private ^:always-validate update-db-permissions! [group-id :- su/IntGreaterThanZero, db-id :- su/IntGreaterThanZero, new-db-perms :- StrictDBPermissionsGraph]
   (when-let [new-native-perms (:native new-db-perms)]
     (update-native-permissions! group-id db-id new-native-perms))
   (when-let [schemas (:schemas new-db-perms)]
-    (revoke-db-schema-permissions! group-id db-id)
     (cond
-      (= schemas :all)  (grant-permissions-for-all-schemas! group-id db-id)
-      (= schemas :none) nil
+      (= schemas :all)  (do (revoke-db-schema-permissions! group-id db-id)
+                            (grant-permissions-for-all-schemas! group-id db-id))
+      (= schemas :none) (revoke-db-schema-permissions! group-id db-id)
       (map? schemas)    (doseq [schema (keys schemas)]
                           (update-schema-perms! group-id db-id schema (get-in new-db-perms [:schemas schema]))))))
 
-(s/defn ^:private ^:always-validate update-group-permissions! [group-id :- s/Int, new-group-perms :- StrictGroupPermissionsGraph]
-  (doseq [db-id (keys new-group-perms)]
-    (update-db-permissions! group-id db-id (get new-group-perms db-id))))
+(s/defn ^:private ^:always-validate update-group-permissions! [group-id :- su/IntGreaterThanZero, new-group-perms :- StrictGroupPermissionsGraph]
+  (doseq [[db-id new-db-perms] new-group-perms]
+    (update-db-permissions! group-id db-id new-db-perms)))
 
 
-(defn- check-revision-numbers
+(defn check-revision-numbers
   "Check that the revision number coming in as part of NEW-GRAPH matches the one from OLD-GRAPH.
    This way we can make sure people don't submit a new graph based on something out of date,
    which would otherwise stomp over changes made in the interim.
@@ -433,6 +463,12 @@
       :after   new
       :user_id *current-user-id*)))
 
+(defn log-permissions-changes
+  "Log changes to the permissions graph."
+  [old new]
+  (log/debug (format "Changing permissions: 🔏\nFROM:\n%s\nTO:\n%s\n"
+                     (u/pprint-to-str 'magenta old)
+                     (u/pprint-to-str 'blue new))))
 
 (s/defn ^:always-validate update-graph!
   "Update the permissions graph, making any changes neccesary to make it match NEW-GRAPH.
@@ -443,14 +479,12 @@
    (let [old-graph (graph)
          [old new] (data/diff (:groups old-graph) (:groups new-graph))]
      (when (or (seq old) (seq new))
-       (log/debug (format "Changing permissions: 🔏\nFROM:\n%s\nTO:\n%s"
-                         (u/pprint-to-str 'magenta old)
-                         (u/pprint-to-str 'blue new)))
+       (log-permissions-changes old new)
        (check-revision-numbers old-graph new-graph)
        (db/transaction
-        (doseq [group-id (keys new)]
-          (update-group-permissions! group-id (get new group-id)))
-        (save-perms-revision! (:revision old-graph) old new)))))
+         (doseq [[group-id changes] new]
+           (update-group-permissions! group-id changes))
+         (save-perms-revision! (:revision old-graph) old new)))))
   ;; The following arity is provided soley for convenience for tests/REPL usage
   ([ks new-value]
    {:pre [(sequential? ks)]}
diff --git a/src/metabase/query_processor/annotate.clj b/src/metabase/query_processor/annotate.clj
index eb7cde1a58ce3a7b43ffdcf2067acaf538bc8c3d..fe6e778d5d501001083edeadd71489478413ff18 100644
--- a/src/metabase/query_processor/annotate.clj
+++ b/src/metabase/query_processor/annotate.clj
@@ -94,18 +94,24 @@
     :count
     ag-type))
 
+;; TODO - rename to something like `aggregation-name` or `aggregation-subclause-name` now that this handles any sort of aggregation
 (defn expression-aggregation-name
-  "Return an appropriate name for an expression aggregation, e.g. `sum + count`."
-  ^String [ag]
+  "Return an appropriate name for an `:aggregation` subclause (an aggregation or expression)."
+  ^String [{custom-name :custom-name, aggregation-type :aggregation-type, :as ag}]
   (cond
+    ;; if a custom name was provided use it
+    custom-name               custom-name
+    ;; for unnamed expressions, just compute a name like "sum + count"
     (instance? Expression ag) (let [{:keys [operator args]} ag]
                                 (str/join (str " " (name operator) " ")
                                           (for [arg args]
                                             (if (instance? Expression arg)
                                               (str "(" (expression-aggregation-name arg) ")")
                                               (expression-aggregation-name arg)))))
-    (:aggregation-type ag)    (name (:aggregation-type ag))
-    :else                     ag))
+    ;; for unnamed normal aggregations, the column alias is always the same as the ag type except for `:distinct` with is called `:count` (WHY?)
+    aggregation-type          (if (= (keyword aggregation-type) :distinct)
+                                "count"
+                                (name aggregation-type))))
 
 (defn- expression-aggregate-field-info [expression]
   (let [ag-name (expression-aggregation-name expression)]
diff --git a/src/metabase/query_processor/expand.clj b/src/metabase/query_processor/expand.clj
index 2ad8ad88d633ad97846c05f86e9e34454432da32..7c08ac74c45b439df87b418962363aa787aff1ed 100644
--- a/src/metabase/query_processor/expand.clj
+++ b/src/metabase/query_processor/expand.clj
@@ -51,7 +51,7 @@
   [id :- su/IntGreaterThanZero]
   (i/map->FieldPlaceholder {:field-id id}))
 
-(s/defn ^:private ^:always-validate field :- i/AnyField
+(s/defn ^:private ^:always-validate field :- i/AnyFieldOrExpression
   "Generic reference to a `Field`. F can be an integer Field ID, or various other forms like `fk->` or `aggregation`."
   [f]
   (if (integer? f)
@@ -59,6 +59,13 @@
         (field-id f))
     f))
 
+(s/defn ^:ql ^:always-validate named :- i/Aggregation
+  "Specify a CUSTOM-NAME to use for a top-level AGGREGATION-OR-EXPRESSION in the results.
+   (This will probably be extended to support Fields in the future, but for now, only the `:aggregation` clause is supported.)"
+  {:added "0.22.0"}
+  [aggregation-or-expression :- i/Aggregation, custom-name :- su/NonBlankString]
+  (assoc aggregation-or-expression :custom-name custom-name))
+
 (s/defn ^:ql ^:always-validate datetime-field :- FieldPlaceholder
   "Reference to a `DateTimeField`. This is just a `Field` reference with an associated datetime UNIT."
   ([f _ unit] (log/warn (u/format-color 'yellow (str "The syntax for datetime-field has changed in MBQL '98. [:datetime-field <field> :as <unit>] is deprecated. "
@@ -116,11 +123,17 @@
 
 (defn- field-or-expression [f]
   (if (instance? Expression f)
-    (update f :args (partial map field-or-expression)) ; recursively call field-or-expression on all the args of the expression
+    ;; recursively call field-or-expression on all the args inside the expression unless they're numbers
+    ;; plain numbers are always assumed to be numeric literals here; you must use MBQL '98 `:field-id` syntax to refer to Fields inside an expression <3
+    (update f :args #(for [arg %]
+                       (if (number? arg)
+                         arg
+                         (field-or-expression arg))))
+    ;; otherwise if it's not an Expression it's a Field
     (field f)))
 
 (s/defn ^:private ^:always-validate ag-with-field :- i/Aggregation [ag-type f]
-  (i/strict-map->AggregationWithField {:aggregation-type ag-type, :field (field-or-expression f)}))
+  (i/map->AggregationWithField {:aggregation-type ag-type, :field (field-or-expression f)}))
 
 (def ^:ql ^{:arglists '([f])} avg      "Aggregation clause. Return the average value of F."                (partial ag-with-field :avg))
 (def ^:ql ^{:arglists '([f])} distinct "Aggregation clause. Return the number of distinct values of F."    (partial ag-with-field :distinct))
@@ -138,13 +151,13 @@
 
 (s/defn ^:ql ^:always-validate count :- i/Aggregation
   "Aggregation clause. Return total row count (e.g., `COUNT(*)`). If F is specified, only count rows where F is non-null (e.g. `COUNT(f)`)."
-  ([]  (i/strict-map->AggregationWithoutField {:aggregation-type :count}))
+  ([]  (i/map->AggregationWithoutField {:aggregation-type :count}))
   ([f] (ag-with-field :count f)))
 
 (s/defn ^:ql ^:always-validate cum-count :- i/Aggregation
   "Aggregation clause. Return the cumulative row count (presumably broken out in some way)."
   []
-  (i/strict-map->AggregationWithoutField {:aggregation-type :cumulative-count}))
+  (i/map->AggregationWithoutField {:aggregation-type :cumulative-count}))
 
 (defn ^:ql ^:deprecated rows
   "Bare rows aggregation. This is the default behavior, so specifying it is deprecated."
@@ -399,10 +412,10 @@
 
 (s/defn ^:private ^:always-validate expression-fn :- Expression
   [k :- s/Keyword, & args]
-  (i/strict-map->Expression {:operator k, :args (vec (for [arg args]
-                                                       (if (number? arg)
-                                                         (float arg) ; convert args to floats so things like 5 / 10 -> 0.5 instead of 0
-                                                         arg)))}))
+  (i/map->Expression {:operator k, :args (vec (for [arg args]
+                                                (if (number? arg)
+                                                  (float arg) ; convert args to floats so things like 5 / 10 -> 0.5 instead of 0
+                                                  arg)))}))
 
 (def ^:ql ^{:arglists '([rvalue1 rvalue2 & more]), :added "0.17.0"} + "Arithmetic addition function."       (partial expression-fn :+))
 (def ^:ql ^{:arglists '([rvalue1 rvalue2 & more]), :added "0.17.0"} - "Arithmetic subtraction function."    (partial expression-fn :-))
diff --git a/src/metabase/query_processor/interface.clj b/src/metabase/query_processor/interface.clj
index e4d6010e22b282656f150548dbccc44dcd9900f7..526e887800f025b620fb5d0c0d7102f16fd88bba 100644
--- a/src/metabase/query_processor/interface.clj
+++ b/src/metabase/query_processor/interface.clj
@@ -181,14 +181,15 @@
 
 (def ^:private ExpressionOperator (s/named (s/enum :+ :- :* :/) "Valid expression operator"))
 
-(s/defrecord Expression [operator :- ExpressionOperator
-                         args     :- [(s/cond-pre (s/recursive #'RValue)
-                                                  (s/recursive #'Aggregation))]])
+(s/defrecord Expression [operator   :- ExpressionOperator
+                         args       :- [(s/cond-pre (s/recursive #'RValue)
+                                                    (s/recursive #'Aggregation))]
+                         custom-name :- (s/maybe su/NonBlankString)])
 
-(def AnyField
+(def AnyFieldOrExpression
   "Schema for a `FieldPlaceholder`, `AgRef`, or `Expression`."
   (s/named (s/cond-pre ExpressionRef Expression FieldPlaceholderOrAgRef)
-           "Valid field, ag field reference, or expression reference."))
+           "Valid field, ag field reference, expression, or expression reference."))
 
 
 (def LiteralDatetimeString
@@ -241,12 +242,14 @@
 ;;; # ------------------------------------------------------------ CLAUSE SCHEMAS ------------------------------------------------------------
 
 (s/defrecord AggregationWithoutField [aggregation-type :- (s/named (s/enum :count :cumulative-count)
-                                                                   "Valid aggregation type")])
+                                                                   "Valid aggregation type")
+                                      custom-name      :- (s/maybe su/NonBlankString)])
 
 (s/defrecord AggregationWithField [aggregation-type :- (s/named (s/enum :avg :count :cumulative-sum :distinct :max :min :stddev :sum)
                                                                 "Valid aggregation type")
                                    field            :- (s/cond-pre FieldPlaceholderOrExpressionRef
-                                                                   Expression)])
+                                                                   Expression)
+                                   custom-name      :- (s/maybe su/NonBlankString)])
 
 (defn- valid-aggregation-for-driver? [{:keys [aggregation-type]}]
   (when (= aggregation-type :stddev)
@@ -302,7 +305,7 @@
 
 (def OrderBy
   "Schema for top-level `order-by` clause in an MBQL query."
-  (s/named {:field     AnyField
+  (s/named {:field     AnyFieldOrExpression
             :direction OrderByDirection}
            "Valid order-by subclause"))
 
@@ -317,7 +320,7 @@
   "Schema for an MBQL query."
   {(s/optional-key :aggregation) [Aggregation]
    (s/optional-key :breakout)    [FieldPlaceholderOrExpressionRef]
-   (s/optional-key :fields)      [AnyField]
+   (s/optional-key :fields)      [AnyFieldOrExpression]
    (s/optional-key :filter)      Filter
    (s/optional-key :limit)       su/IntGreaterThanZero
    (s/optional-key :order-by)    [OrderBy]
diff --git a/src/metabase/query_processor/macros.clj b/src/metabase/query_processor/macros.clj
index 429cfc30daafafd29b3bd64eca1d43f6de7132d0..3b923679a87a72caa49e1053836cf9b6b0e907ed 100644
--- a/src/metabase/query_processor/macros.clj
+++ b/src/metabase/query_processor/macros.clj
@@ -1,5 +1,8 @@
 (ns metabase.query-processor.macros
+  "TODO - this namespace is ancient and written with MBQL '95 in mind, e.g. it is case-sensitive.
+   At some point this ought to be reworked to be case-insensitive and cleaned up."
   (:require [clojure.core.match :refer [match]]
+            [clojure.walk :as walk]
             [metabase.db :as db]
             [metabase.util :as u]))
 
@@ -18,6 +21,9 @@
          ~@match-forms
          form# (throw (Exception. (format ~(format "%s failed: invalid clause: %%s" fn-name) form#)))))))
 
+
+;;; ------------------------------------------------------------ Segments ------------------------------------------------------------
+
 (defparser segment-parse-filter-subclause
   ["SEGMENT" (segment-id :guard integer?)] (:filter (db/select-one-field :definition 'Segment, :id segment-id))
   subclause  subclause)
@@ -40,50 +46,61 @@
     (seq addtl)       addtl
     :else             []))
 
-(defn- merge-aggregation [aggregations new-ag]
-  (if (map? aggregations)
-    (recur [aggregations] new-ag)
-    (conj aggregations new-ag)))
 
-(defn- merge-aggregations {:style/indent 0} [query-dict [aggregation & more]]
-  (if-not aggregation
-    ;; no more aggregations? we're done
-    query-dict
-    ;; otherwise determine if this aggregation is a METRIC and recur
-    (let [metric-def (match aggregation
-                       ["METRIC" (metric-id :guard integer?)] (db/select-one-field :definition 'Metric, :id metric-id)
-                       _                                      nil)]
-      (recur (if-not metric-def
-               ;; not a metric, move to next aggregation
-               query-dict
-               ;; it *is* a metric, insert it into the query appropriately
-               (-> query-dict
-                   (update-in [:query :aggregation] merge-aggregation (:aggregation metric-def))
-                   (update-in [:query :filter] merge-filter-clauses (:filter metric-def))))
-             more))))
-
-(defn- remove-metrics [aggregations]
-  (if-not (and (sequential? aggregations)
-               (every? coll? aggregations))
-    (recur [aggregations])
-    (vec (for [ag    aggregations
-               :when (match ag
-                       ["METRIC" (_ :guard integer?)] false
-                       _                              true)]
-           ag))))
+;;; ------------------------------------------------------------ Metrics ------------------------------------------------------------
+
+(defn- metric? [aggregation]
+  (match aggregation
+    ["METRIC" (_ :guard integer?)] true
+    _                              false))
+
+(defn- metric-id [metric]
+  (when (metric? metric)
+    (second metric)))
+
+(defn- maybe-unnest-ag-clause
+  "Unnest AG-CLAUSE if it's wrapped in a vector (i.e. if it is using the \"multiple-aggregation\" syntax).
+   (This is provided merely as a convenience to facilitate implementation of the Query Builder, so it can use the same UI for
+   normal aggregations and Metric creation. *METRICS DO NOT SUPPORT MULTIPLE AGGREGATIONS,* so if nested syntax is used, any
+   aggregation after the first will be ignored.)"
+  [ag-clause]
+  (if (and (coll? ag-clause)
+           (every? coll? ag-clause))
+    (first ag-clause)
+    ag-clause))
+
+(defn- expand-metric [metric-clause filter-clauses-atom]
+  (let [{filter-clause :filter, ag-clause :aggregation} (db/select-one-field :definition 'Metric, :id (metric-id metric-clause))]
+    (when filter-clause
+      (swap! filter-clauses-atom conj filter-clause))
+    (maybe-unnest-ag-clause ag-clause)))
+
+(defn- expand-metrics-in-ag-clause [query-dict filter-clauses-atom]
+  (walk/postwalk (fn [form]
+                   (if-not (metric? form)
+                     form
+                     (expand-metric form filter-clauses-atom)))
+                 query-dict))
+
+(defn- add-metrics-filter-clauses [query-dict filter-clauses]
+  (update-in query-dict [:query :filter] merge-filter-clauses (if (> (count filter-clauses) 1)
+                                                                (cons "AND" filter-clauses)
+                                                                (first filter-clauses))))
+
+(defn- expand-metrics [query-dict]
+  (let [filter-clauses-atom (atom [])
+        query-dict          (expand-metrics-in-ag-clause query-dict filter-clauses-atom)]
+    (add-metrics-filter-clauses query-dict @filter-clauses-atom)))
 
 (defn- macroexpand-metric [{{aggregations :aggregation} :query, :as query-dict}]
   (if-not (seq aggregations)
     ;; :aggregation is empty, so no METRIC to expand
     query-dict
-    ;; we have an aggregation clause, so lets see if we are using a METRIC
-    ;; (since `:aggregation` can be either single or multiple, wrap single ones so `merge-aggregations` can always assume input is multiple)
-    (merge-aggregations
-      (update-in query-dict [:query :aggregation] remove-metrics)
-      (if (and (sequential? aggregations)
-               (every? coll? aggregations))
-        aggregations
-        [aggregations]))))
+    ;; otherwise walk the query dict and expand METRIC clauses
+    (expand-metrics query-dict)))
+
+
+;;; ------------------------------------------------------------ Middleware ------------------------------------------------------------
 
 (defn expand-macros "Expand the macros (SEGMENT, METRIC) in a QUERY-DICT."
   [query-dict]
diff --git a/src/metabase/query_processor/parameters.clj b/src/metabase/query_processor/parameters.clj
index 396b70f779ef7e912cf68cc5a39abbb71fe51fab..af2793c722d002bb37962d3773ac7966516f9e64 100644
--- a/src/metabase/query_processor/parameters.clj
+++ b/src/metabase/query_processor/parameters.clj
@@ -46,7 +46,7 @@
    :start (t/first-day-of-the-month dt)})
 
 (defn- year-range [^DateTime dt]
-  {:end   (t/last-day-of-the-month (.withMonthOfYear dt DateTimeConstants/DECEMBER))
+  {:end   (t/last-day-of-the-month  (.withMonthOfYear dt DateTimeConstants/DECEMBER))
    :start (t/first-day-of-the-month (.withMonthOfYear dt DateTimeConstants/JANUARY))})
 
 (defn- absolute-date->range
diff --git a/src/metabase/query_processor/permissions.clj b/src/metabase/query_processor/permissions.clj
index acdeb24e9fc742ed3a29c9873f863b0bc6dfbb17..909382040a6d03e4c051af0428ade0da6645018d 100644
--- a/src/metabase/query_processor/permissions.clj
+++ b/src/metabase/query_processor/permissions.clj
@@ -1,10 +1,11 @@
 (ns metabase.query-processor.permissions
   "Logic related to whether a given user has permissions to run/edit a given query."
-  ;; TODO - everything in this namespace predates the newer implementations of Permissions checking on a model-by-model basis
-  ;;        They essentially do the same thing. This has better logging but the other has more flexibilitiy.
-  ;;        At some point we will need to merge the two approaches.
+  ;; TODO - Some functions this namespace predates the newer implementations of Permissions checking on a model-by-model basis (and `*current-user-permissions-set*`)
+  ;;        They essentially do the same thing. This has better logging but the other has more flexibilitiy and is simpler to user.
+  ;;        At some point we should rework this namespace to use the newer approach.
   (:require [clojure.tools.logging :as log]
             [honeysql.core :as hsql]
+            [metabase.api.common :refer [*current-user-permissions-set*]]
             [metabase.db :as db]
             [metabase.models.permissions :as perms]
             [metabase.util :as u]
@@ -17,10 +18,15 @@
   (let [appropriate-lock-emoji (if (= color 'yellow)
                                  "🔒"   ; lock (closed)
                                  "🔓")] ; lock (open
-    (log/debug (u/format-color color (apply format (format "Permissions Check %s : %s" appropriate-lock-emoji format-str) format-args)))))
+    (println #_log/debug (u/format-color color (apply format (format "Permissions Check %s : %s" appropriate-lock-emoji format-str) format-args)))))
 
-(defn- log-permissions-success [user-id permissions]
-  (log-permissions-debug-message 'green "Yes ✅  because User %d is a member of Group %d (%s) which has permissions for '%s'"
+(defn- log-permissions-success-message {:style/indent 1} [format-string & format-args]
+  (log-permissions-debug-message 'green (str "Yes ✅  " (apply format format-string format-args))))
+
+;; DEPRECATED because to use this function we need to have an actual `Permissions` object instead of being able to use *current-user-permissions-set*.
+;; Use `log-permissions-success-message` instead.
+(defn- ^:deprecated log-permissions-success [user-id permissions]
+  (log-permissions-success-message "because User %d is a member of Group %d (%s) which has permissions for '%s'"
     user-id
     (:group_id permissions)
     (db/select-one-field :name 'PermissionsGroup :id (:group_id permissions))
@@ -34,19 +40,24 @@
   (log-permissions-error)
   (throw (Exception. ^String (apply format format-str format-args))))
 
-(defn- permissions-for-object [user-id object-path]
+;; DEPRECATED because we should just check it the "new" way instead: (perms/set-has-full-permissions? @*current-user-permissions-set* object-path)
+(defn- ^:deprecated permissions-for-object
+  "Return the first `Permissions` entry for USER-ID that grants permissions to OBJECT-PATH."
+  [user-id object-path]
   {:pre [(integer? user-id) (perms/valid-object-path? object-path)]}
   (u/prog1 (db/select-one 'Permissions
              {:where [:and [:in :group_id (db/select-field :group_id 'PermissionsGroupMembership :user_id user-id)]
-                      [:like object-path (hx/concat :object (hx/literal "%"))]]})
+                           [:like object-path (hx/concat :object (hx/literal "%"))]]})
     (when <>
       (log-permissions-success user-id <>))))
 
 
 ;;; ------------------------------------------------------------ Permissions for MBQL queries ------------------------------------------------------------
 
-;; TODO - the performance of this could be improved a bit by doing a join or even caching results
-(defn user-can-run-query-referencing-table?
+;; TODO - All of this below should be rewritten to use `*current-user-permissions-set*` and `metabase.models.card/query-perms-set` instead.
+;; The functions that need to be reworked are marked DEPRECATED below.
+
+(defn- ^:deprecated user-can-run-query-referencing-table?
   "Does User with USER-ID have appropriate permissions to run an MBQL query referencing table with TABLE-ID?"
   [user-id table-id]
   {:pre [(integer? user-id) (integer? table-id)]}
@@ -54,56 +65,77 @@
     (permissions-for-object user-id (perms/object-path database-id schema table-id))))
 
 
-(defn- table-id [source-or-join-table]
+(defn- ^:deprecated table-id [source-or-join-table]
   (or (:id source-or-join-table)
       (:table-id source-or-join-table)))
 
-(defn- table-identifier ^String [source-or-join-table]
+(defn- ^:deprecated table-identifier ^String [source-or-join-table]
   (name (hsql/qualify (:schema source-or-join-table) (or (:name source-or-join-table)
                                                          (:table-name source-or-join-table)))))
 
 
-(defn- throw-if-cannot-run-query-referencing-table [user-id table]
+(defn- ^:deprecated throw-if-cannot-run-query-referencing-table [user-id table]
   (log-permissions-debug-message 'yellow "Can User %d access Table %d (%s)?" user-id (table-id table) (table-identifier table))
   (or (user-can-run-query-referencing-table? user-id (table-id table))
       (throw-permissions-exception "You do not have permissions to run queries referencing table '%s'." (table-identifier table))))
 
+(defn- throw-if-cannot-run-query
+  "Throw an exception if USER-ID doesn't have permissions to run QUERY."
+  [user-id {:keys [source-table join-tables]}]
+  (doseq [table (cons source-table join-tables)]
+    (throw-if-cannot-run-query-referencing-table user-id table)))
+
 
 ;;; ------------------------------------------------------------ Permissions for Native Queries ------------------------------------------------------------
 
+(defn- throw-if-user-doesnt-have-permissions-for-path
+  "Check whether current user has permissions for OBJECT-PATH, and throw an exception if not.
+   Log messages related to the permissions checks as well."
+  [object-path]
+  (log-permissions-debug-message 'yellow "Does user have permissions for %s?" object-path)
+  (when-not (perms/set-has-full-permissions? @*current-user-permissions-set* object-path)
+    (throw-permissions-exception "You do not have read permissions for %s." object-path))
+  ;; permissions check out, now log which perms we've been granted that allowed our escapades to proceed
+  (log-permissions-success-message "because user has permissions for %s." (some (fn [permissions-path]
+                                                                                  (when (perms/is-permissions-for-object? permissions-path object-path)
+                                                                                    permissions-path))
+                                                                                @*current-user-permissions-set*)))
+
 (defn throw-if-cannot-run-new-native-query-referencing-db
   "Throw an exception if User with USER-ID doesn't have native query *readwrite* permissions for DATABASE."
-  [user-id {database-id :id, database-name :name}]
-  {:pre [(integer? database-id)]}
-  (log-permissions-debug-message 'yellow "Can User %d run *new* native queries against Database %d (%s)?" user-id database-id database-name)
-  (or (permissions-for-object user-id (perms/native-readwrite-path database-id))
-      (throw-permissions-exception "You do not have permissions to run new native queries against database '%s'." database-name)))
+  [database-or-id]
+  (throw-if-user-doesnt-have-permissions-for-path (perms/native-readwrite-path (u/get-id database-or-id))))
 
+(defn- ^:deprecated throw-if-cannot-run-existing-native-query-referencing-db
+  "Throw an exception if User with USER-ID doesn't have native query *read* permissions for DATABASE.
+   (DEPRECATED because native read permissions are being eliminated in favor of Collection permissions.)"
+  [database-or-id]
+  (throw-if-user-doesnt-have-permissions-for-path (perms/native-read-path (u/get-id database-or-id))))
 
-(defn- throw-if-cannot-run-existing-native-query-referencing-db
-  "Throw an exception if User with USER-ID doesn't have native query *read* permissions for DATABASE."
-  [user-id {database-id :id, database-name :name}]
-  {:pre [(integer? database-id)]}
-  (log-permissions-debug-message 'yellow "Can User %d run *existing* native queries against Database %d (%s)?" user-id database-id database-name)
-  (or (permissions-for-object user-id (perms/native-read-path database-id))
-      (throw-permissions-exception "You do not have permissions to run existing native queries against database '%s'." database-name)))
+(defn- throw-if-user-doesnt-have-access-to-collection
+  "Throw an exception if the current User doesn't have permissions to run a Card that is part of COLLECTION."
+  [collection-id]
+  (throw-if-user-doesnt-have-permissions-for-path (perms/collection-read-path collection-id)))
 
 
 ;;; ------------------------------------------------------------ Middleware ------------------------------------------------------------
 
 (defn check-query-permissions
   "Check that User with USER-ID has permissions to run QUERY, or throw an exception."
-  [user-id {query-type :type, database :database, {:keys [source-table join-tables]} :query, {card-id :card-id} :info}]
+  [user-id {query-type :type, database :database, query :query, {card-id :card-id} :info}]
   {:pre [(integer? user-id)]}
-  (let [native? (= (keyword query-type) :native)]
+  (let [native?       (= (keyword query-type) :native)
+        collection-id (db/select-one-field :collection_id 'Card :id card-id)]
     (cond
+      ;; if the card is in a COLLECTION, then see if the current user has permissions for that collection
+      collection-id
+      (throw-if-user-doesnt-have-access-to-collection collection-id)
       ;; for native queries that are *not* part of an existing card, check that we have native permissions for the DB
       (and native? (not card-id))
-      (throw-if-cannot-run-new-native-query-referencing-db user-id database)
-      ;; for native queries that *are* part of an existing card, no checks are done
+      (throw-if-cannot-run-new-native-query-referencing-db database)
+      ;; for native queries that *are* part of an existing card, just check if the have native read permissions (DEPRECATED)
       native?
-      (throw-if-cannot-run-existing-native-query-referencing-db user-id database)
-      ;; for MBQL queries, check that we can run against the source-table. and each of the join-tables, if any
+      (throw-if-cannot-run-existing-native-query-referencing-db database)
+      ;; for MBQL queries (existing card or not), check that we can run against the source-table. and each of the join-tables, if any
       (not native?)
-      (doseq [table (cons source-table join-tables)]
-        (throw-if-cannot-run-query-referencing-table user-id table)))))
+      (throw-if-cannot-run-query user-id query))))
diff --git a/src/metabase/query_processor/sql_parameters.clj b/src/metabase/query_processor/sql_parameters.clj
index 212ad35a80128768d1b860c0ef8419208efb5a8b..3a62ea0462a029329795c8ee0ca40de5d3fc73d9 100644
--- a/src/metabase/query_processor/sql_parameters.clj
+++ b/src/metabase/query_processor/sql_parameters.clj
@@ -46,21 +46,10 @@
   (first (hsql/format x
            :quoting ((resolve 'metabase.driver.generic-sql/quote-style) *driver*))))
 
-(defn- format-oracle-date [s]
-  (format "to_timestamp('%s', 'YYYY-MM-DD')" (u/format-date "yyyy-MM-dd" (u/->Date s))))
-
-(defn- oracle-driver? ^Boolean []
-  ;; we can't just import OracleDriver the normal way here because that would cause a cyclic load dependency
-  (boolean (when-let [oracle-driver-class (u/ignore-exceptions (Class/forName "metabase.driver.oracle.OracleDriver"))]
-             (instance? oracle-driver-class *driver*))))
-
-(defn- format-date
-  ;; This is a dirty dirty HACK! Unfortuantely Oracle is super-dumb when it comes to automatically converting strings to dates
-  ;; so we need to add the cast here
-  [date]
-  (if (oracle-driver?)
-    (format-oracle-date date)
-    (str \' date \')))
+(defn- format-date-string
+  "Format DATE-STRING as an appropriate literal using the driver's definition of `date-string->literal`."
+  ^String [^String date-string]
+  (honeysql->sql ((resolve 'metabase.driver.generic-sql/date-string->literal) *driver* date-string)))
 
 (extend-protocol ISQLParamSubstituion
   nil         (->sql [_]    "NULL")
@@ -73,20 +62,20 @@
 
   FieldInstance
   (->sql [this]
-    (->sql (let [identifier (apply hsql/qualify (field/qualified-name-components this))]
+    (->sql (let [identifier ((resolve 'metabase.driver.generic-sql/field->identifier) *driver* this)]
              (if (re-find #"^date/" (:type this))
                ((resolve 'metabase.driver.generic-sql/date) *driver* :day identifier)
                identifier))))
 
   Date
   (->sql [{:keys [s]}]
-    (format-date s))
+    (format-date-string s))
 
   DateRange
   (->sql [{:keys [start end]}]
     (if (= start end)
-      (format "= %s" (format-date start))
-      (format "BETWEEN %s AND %s" (format-date start) (format-date end))))
+      (format "= %s" (format-date-string start))
+      (format "BETWEEN %s AND %s" (format-date-string start) (format-date-string end))))
 
   Dimension
   (->sql [{:keys [field param], :as dimension}]
@@ -175,10 +164,10 @@
 
 (defn- parse-value-for-type [param-type value]
   (cond
-    (= param-type "number")                                (->NumberValue value)
+    (= param-type "number")                          (->NumberValue value)
     (and (= param-type "dimension")
-         (= (get-in value [:param :type]) "number"))       (update-in value [:param :value] ->NumberValue)
-    :else                                                  value))
+         (= (get-in value [:param :type]) "number")) (update-in value [:param :value] ->NumberValue)
+    :else                                            value))
 
 (defn- value-for-tag
   "Given a map TAG (a value in the `:template_tags` dictionary) return the corresponding value from the PARAMS sequence.
diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj
index 4f32b9ca4d0cf5a7f841320b90c2b087cf3b8b26..21fd8ab8b701488d1ae20ea8f45e039e782a9928 100644
--- a/src/metabase/routes.clj
+++ b/src/metabase/routes.clj
@@ -15,7 +15,7 @@
                                {:bootstrap_json (json/generate-string (public-settings/public-settings))})
         (slurp (io/resource "frontend_client/init.html")))
       resp/response
-      (resp/content-type "text/html")))
+      (resp/content-type "text/html; charset=utf-8")))
 
 ;; Redirect naughty users who try to visit a page other than setup if setup is not yet complete
 (defroutes ^{:doc "Top-level ring routes for Metabase."} routes
diff --git a/src/metabase/sync_database/sync.clj b/src/metabase/sync_database/sync.clj
index 2fe011390cc071e3c228a752c43b88a2a64aab99..0b0802964e0cece9c0e409bb8459bd85fe0ccbd0 100644
--- a/src/metabase/sync_database/sync.clj
+++ b/src/metabase/sync_database/sync.clj
@@ -136,38 +136,61 @@
       (catch Throwable t
         (log/error (u/format-color 'red "Unexpected error syncing table") t)))))
 
-(def ^:private ^:const crufty-table-names
-  "Names of Tables that should automatically given the `visibility-type` of `:cruft`.
+(def ^:private ^:const crufty-table-patterns
+  "Regular expressions that match Tables that should automatically given the `visibility-type` of `:cruft`.
    This means they are automatically hidden to users (but can be unhidden in the admin panel).
    These `Tables` are known to not contain useful data, such as migration or web framework internal tables."
   #{;; Django
-    "auth_group"
-    "auth_group_permissions"
-    "auth_permission"
-    "django_admin_log"
-    "django_content_type"
-    "django_migrations"
-    "django_session"
-    "django_site"
-    "south_migrationhistory"
-    "user_groups"
-    "user_user_permissions"
+    #"^auth_group$"
+    #"^auth_group_permissions$"
+    #"^auth_permission$"
+    #"^django_admin_log$"
+    #"^django_content_type$"
+    #"^django_migrations$"
+    #"^django_session$"
+    #"^django_site$"
+    #"^south_migrationhistory$"
+    #"^user_groups$"
+    #"^user_user_permissions$"
+    ;; Drupal
+    #".*_cache$"
+    #".*_revision$"
+    #"^advagg_.*"
+    #"^apachesolr_.*"
+    #"^authmap$"
+    #"^autoload_registry.*"
+    #"^batch$"
+    #"^blocked_ips$"
+    #"^cache.*"
+    #"^captcha_.*"
+    #"^config$"
+    #"^field_revision_.*"
+    #"^flood$"
+    #"^node_revision.*"
+    #"^queue$"
+    #"^rate_bot_.*"
+    #"^registry.*"
+    #"^router.*"
+    #"^semaphore$"
+    #"^sequences$"
+    #"^sessions$"
+    #"^watchdog$"
     ;; Rails / Active Record
-    "schema_migrations"
+    #"^schema_migrations$"
     ;; PostGIS
-    "spatial_ref_sys"
+    #"^spatial_ref_sys$"
     ;; nginx
-    "nginx_access_log"
+    #"^nginx_access_log$"
     ;; Liquibase
-    "databasechangelog"
-    "databasechangeloglock"
+    #"^databasechangelog$"
+    #"^databasechangeloglock$"
     ;; Lobos
-    "lobos_migrations"})
+    #"^lobos_migrations$"})
 
 (defn- is-crufty-table?
   "Should we give newly created TABLE a `visibility_type` of `:cruft`?"
   [table]
-  (contains? crufty-table-names (s/lower-case (:name table))))
+  (boolean (some #(re-find % (s/lower-case (:name table))) crufty-table-patterns)))
 
 (defn is-metabase-metadata-table?
   "Is this TABLE the special `_metabase_metadata` table?"
diff --git a/src/metabase/util.clj b/src/metabase/util.clj
index 7c2c5a3586487a87c15eb7453fc51a84c57da183..acc7557e5b908dabfa3ff24a238965a1dd019fab 100644
--- a/src/metabase/util.clj
+++ b/src/metabase/util.clj
@@ -670,13 +670,16 @@
 
 (defn slugify
   "Return a version of `String` S appropriate for use as a URL slug.
-   Downcase the name and replace non-alphanumeric characters with underscores."
-  ^String [s]
-  (when (seq s)
-    (s/join (for [c (s/lower-case (name s))]
-              (if (contains? slugify-valid-chars c)
-                c
-                \_)))))
+   Downcase the name and replace non-alphanumeric characters with underscores.
+   Optionally specify MAX-LENGTH which will truncate the slug after that many characters."
+  (^String [s]
+   (when (seq s)
+     (s/join (for [c (s/lower-case (name s))]
+               (if (contains? slugify-valid-chars c)
+                 c
+                 \_)))))
+  (^String [s max-length]
+   (s/join (take max-length (slugify s)))))
 
 (defn do-with-auto-retries
   "Execute F, a function that takes no arguments, and return the results.
diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj
index 5a18118781f1ac8be144207b48258949928cf2f1..507918e454fb64b1b35a0e5f620203d32791ddd1 100644
--- a/src/metabase/util/schema.clj
+++ b/src/metabase/util/schema.clj
@@ -14,12 +14,15 @@
   {:pre [(map? schema)]}
   (assoc schema :api-error-message api-error-message))
 
-(def ^:private existing-schema->api-error-message
+(defn- existing-schema->api-error-message
   "Error messages for various schemas already defined in `schema.core`.
    These are used as a fallback by API param validation if no value for `:api-error-message` is present."
-  {s/Int  "value must be an integer."
-   s/Str  "value must be a string."
-   s/Bool "value must be a boolean."})
+  [existing-schema]
+  (cond
+    (= existing-schema s/Int)                           "value must be an integer."
+    (= existing-schema s/Str)                           "value must be a string."
+    (= existing-schema s/Bool)                          "value must be a boolean."
+    (instance? java.util.regex.Pattern existing-schema) (format "value must be a string that matches the regex `%s`." existing-schema)))
 
 (defn api-error-message
   "Extract the API error messages attached to a schema, if any.
@@ -58,6 +61,7 @@
   (with-api-error-message (s/constrained s/Str (complement str/blank?) "Non-blank string")
     "value must be a non-blank string."))
 
+;; TODO - rename this to `PositiveInt`?
 (def IntGreaterThanZero
   "Schema representing an integer than must also be greater than zero."
   (with-api-error-message
diff --git a/src/metabase/util/stats.clj b/src/metabase/util/stats.clj
index e3e6ddcf6c8616fc951d9e3c6329eb8727851890..a9c3000403735634e773aa1845d305c118a2fcfd 100644
--- a/src/metabase/util/stats.clj
+++ b/src/metabase/util/stats.clj
@@ -149,7 +149,7 @@
   "Get metrics based on user records
   TODO: get activity in terms of created questions, pulses and dashboards"
   []
-  (let [users (db/select 'User)]
+  (let [users (db/select ['User :is_active :is_superuser :last_login :google_auth])]
     {:users (apply add-summaries (map user-dims users))}))
 
 
@@ -157,7 +157,7 @@
   "Get metrics based on groups:
   TODO characterize by # w/ sql access, # of users, no self-serve data access"
   []
-  (let [groups (db/select 'PermissionsGroup)]
+  (let [groups (db/select ['PermissionsGroup :id])]
     {:groups (count groups)}))
 
 ;; Artifact Metrics
@@ -173,15 +173,15 @@
   "Get metrics based on questions
   TODO characterize by # executions and avg latency"
   []
-  (let [questions (db/select 'Card)]
+  (let [questions (db/select ['Card :id :query_type])]
     {:questions (apply add-summaries (map question-dims questions))}))
 
 (defn- dashboard-metrics
   "Get metrics based on dashboards
   TODO characterize by # of revisions, and created by an admin"
   []
-  (let [dashboards (db/select 'Dashboard)
-        dashcards (db/select 'DashboardCard)]
+  (let [dashboards (db/select ['Dashboard :id :creator_id])
+        dashcards (db/select ['DashboardCard :id :card_id :dashboard_id])]
     {:dashboards (count dashboards)
      :num_dashs_per_user (medium-histogram dashboards :creator_id)
      :num_cards_per_dash (medium-histogram dashcards :dashboard_id)
@@ -191,9 +191,9 @@
   "Get mes based on pulses
   TODO: characterize by non-user account emails, # emails"
   []
-  (let [pulses (db/select 'Pulse)
-        pulsecards (db/select 'PulseCard)
-        pulsechannels (db/select 'PulseChannel)]
+  (let [pulses (db/select ['Pulse :id :creator_id])
+        pulsecards (db/select ['PulseCard :id :card_id :pulse_id])
+        pulsechannels (db/select ['PulseChannel :channel_type :schedule_type])]
     {:pulses (count pulses)
      :pulse_types (frequencies (map :channel_type pulsechannels))
      :pulse_schedules (frequencies (map :schedule_type pulsechannels))
@@ -205,10 +205,24 @@
 (defn- label-metrics
   "Get metrics based on labels"
   []
-  (let [labels (db/select 'CardLabel)]
+  (let [labels (db/select 'Label)
+        cardlabels (db/select ['CardLabel :card_id :label_id])]
     {:labels (count labels)
-     :num_labels_per_card (micro-histogram labels :card_id)
-     :num_cards_per_label (medium-histogram labels :label_id)}))
+     :num_labels_per_card (micro-histogram cardlabels :card_id)
+     :num_cards_per_label (medium-histogram cardlabels :label_id)}))
+
+
+(defn- collection-metrics
+  "Get metrics on collection usage"
+  []
+  (let [collections (db/select 'Collection)
+        cards (db/select ['Card :collection_id])]
+    {:collections (count collections)
+     :cards_in_collections (count (filter (comp nil?) (map :collection_id cards)))
+     :cards_not_in_collections (count (filter nil? (map :collection_id cards)))
+     :num_cards_per_collection (medium-histogram cards :collection_id)}
+    )
+  )
 
 ;; Metadata Metrics
 (defn- database-dims
@@ -220,14 +234,14 @@
 (defn- database-metrics
   "Get metrics based on databases"
   []
-  (let [databases (db/select 'Database)]
+  (let [databases (db/select ['Database :id :is_full_sync])]
     {:databases (apply add-summaries (map database-dims databases))}))
 
 
 (defn- table-metrics
   "Get metrics based on tables"
   []
-  (let [tables (db/select 'Table)]
+  (let [tables (db/select ['Table :id :db_id :schema])]
     {:tables (count tables)
      :num_per_database (medium-histogram tables :db_id)
      :num_per_schema (medium-histogram tables :schema)}))
@@ -236,21 +250,21 @@
 (defn- field-metrics
   "Get metrics based on fields"
   []
-  (let [fields (db/select 'Field)]
+  (let [fields (db/select ['Field :id :table_id])]
     {:fields (count fields)
      :num_per_table (medium-histogram fields :table_id)}))
 
 (defn- segment-metrics
   "Get metrics based on segments"
   []
-  (let [segments (db/select 'Segment)]
+  (let [segments (db/select ['Segment :id])]
     {:segments (count segments)}))
 
 
 (defn- metric-metrics
   "Get metrics based on metrics"
   []
-  (let [metrics (db/select 'Metric)]
+  (let [metrics (db/select ['Metric :id])]
     {:metrics (count metrics)}))
 
 
@@ -291,6 +305,7 @@
                      :metric (metric-metrics)
                      :group (group-metrics)
                      :label (label-metrics)
+                     :collection (collection-metrics)
                      :execution (execution-metrics)}}))
 
 
diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj
index 7ada2cf79fc81e395f82f262cc21e4e8b4f3e889..f01ade9cc49b5fc134ddaf04138dc23182b8bc6a 100644
--- a/test/metabase/api/card_test.clj
+++ b/test/metabase/api/card_test.clj
@@ -8,6 +8,7 @@
             (metabase.models [card :refer [Card]]
                              [card-favorite :refer [CardFavorite]]
                              [card-label :refer [CardLabel]]
+                             [collection :refer [Collection]]
                              [database :refer [Database]]
                              [label :refer [Label]]
                              [permissions :refer [Permissions], :as perms]
@@ -17,7 +18,8 @@
             [metabase.test.data :refer :all]
             [metabase.test.data.users :refer :all]
             [metabase.test.util :refer [match-$ random-name with-temp with-temp* obj->json->obj expect-with-temp]]
-            [metabase.util :as u]))
+            [metabase.util :as u]
+            [metabase.test.util :as tu]))
 
 ;; # CARD LIFECYCLE
 
@@ -130,6 +132,10 @@
   [card-2-id]
   (map :id ((user->client :rasta) :get 200 "card", :label "more_toucans")))                 ; filtering is done by slug
 
+(defn- mbql-count-query [database-id table-id]
+  {:database database-id
+   :type     "query"
+   :query    {:source-table table-id, :aggregation {:aggregation-type "count"}}})
 
 ;; ## POST /api/card
 ;; Test that we can make a card
@@ -139,30 +145,27 @@
     {:description            nil
      :name                   card-name
      :creator_id             (user->id :rasta)
-     :dataset_query          {:database database-id
-                              :type     "query"
-                              :query    {:source-table table-id, :aggregation {:aggregation-type "count"}}}
+     :dataset_query          (mbql-count-query database-id table-id)
      :display                "scalar"
      :visualization_settings {:global {:title nil}}
      :database_id            database-id ; these should be inferred automatically
      :table_id               table-id
      :query_type             "query"
+     :collection_id          nil
      :archived               false}
-    (dissoc ((user->client :rasta) :post 200 "card" {:name                   card-name
-                                                     :display                "scalar"
-                                                     :dataset_query          {:database database-id
-                                                                              :type     :query
-                                                                              :query    {:source-table table-id, :aggregation {:aggregation-type :count}}}
-                                                     :visualization_settings {:global {:title nil}}})
+    ;; make sure we clean up after ourselves as well and delete the Card we create
+    (dissoc (u/prog1 ((user->client :rasta) :post 200 "card" {:name                   card-name
+                                                              :display                "scalar"
+                                                              :dataset_query          (mbql-count-query database-id table-id)
+                                                              :visualization_settings {:global {:title nil}}})
+              (db/cascade-delete! Card :id (u/get-id <>)))
             :created_at :updated_at :id)))
 
 ;; ## GET /api/card/:id
 ;; Test that we can fetch a card
 (expect-with-temp [Database  [{database-id :id}]
                    Table     [{table-id :id}   {:db_id database-id}]
-                   Card      [card             {:dataset_query {:database database-id
-                                                                :type     :query
-                                                                :query    {:source-table table-id, :aggregation {:aggregation-type :count}}}}]]
+                   Card      [card             {:dataset_query (mbql-count-query database-id table-id)}]]
   (match-$ card
     {:description            nil
      :dashboard_count        0
@@ -188,6 +191,8 @@
      :database_id            database-id ; these should be inferred from the dataset_query
      :table_id               table-id
      :query_type             "query"
+     :collection_id          nil
+     :collection             nil
      :archived               false
      :labels                 []})
   ((user->client :rasta) :get 200 (str "card/" (u/get-id card))))
@@ -195,9 +200,7 @@
 ;; Check that a user without permissions isn't allowed to fetch the card
 (expect-with-temp [Database  [{database-id :id}]
                    Table     [{table-id :id}   {:db_id database-id}]
-                   Card      [card             {:dataset_query {:database database-id
-                                                                :type     :query
-                                                                :query    {:source-table table-id, :aggregation {:aggregation-type :count}}}}]]
+                   Card      [card             {:dataset_query (mbql-count-query database-id table-id)}]]
   "You don't have permissions to do that."
   (do
     ;; revoke permissions for default group to this database
@@ -387,3 +390,169 @@
   (do-with-temp-native-card-with-params
     (fn [database-id card]
       ((user->client :rasta) :post 200 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params)))))
+
+
+;;; +------------------------------------------------------------------------------------------------------------------------+
+;;; |                                                      COLLECTIONS                                                       |
+;;; +------------------------------------------------------------------------------------------------------------------------+
+
+;; Make sure we can create a card and specify its `collection_id` at the same time
+(tu/expect-with-temp [Collection [collection]]
+  (u/get-id collection)
+  (do
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection)
+    (let [{card-id :id} ((user->client :rasta) :post 200 "card" {:name                   "My Cool Card"
+                                                                 :display                "scalar"
+                                                                 :dataset_query          (mbql-count-query (id) (id :venues))
+                                                                 :visualization_settings {:global {:title nil}}
+                                                                 :collection_id          (u/get-id collection)})]
+      ;; make sure we clean up after ourselves and delete the newly created Card
+      (u/prog1 (db/select-one-field :collection_id Card :id card-id)
+        (db/cascade-delete! Card :id card-id)))))
+
+;; Make sure we card creation fails if we try to set a `collection_id` we don't have permissions for
+(tu/expect-with-temp [Collection [collection]]
+  "You don't have permissions to do that."
+  ((user->client :rasta) :post 403 "card" {:name                   "My Cool Card"
+                                           :display                "scalar"
+                                           :dataset_query          (mbql-count-query (id) (id :venues))
+                                           :visualization_settings {:global {:title nil}}
+                                           :collection_id          (u/get-id collection)}))
+
+;; Make sure we can change the `collection_id` of a Card if it's not in any collection
+(tu/expect-with-temp [Card       [card]
+                      Collection [collection]]
+  (u/get-id collection)
+  (do
+    ((user->client :crowberto) :put 200 (str "card/" (u/get-id card)) {:collection_id (u/get-id collection)})
+    (db/select-one-field :collection_id Card :id (u/get-id card))))
+
+;; Make sure we can still change *anything* for a Card if we don't have permissions for the Collection it belongs to
+(tu/expect-with-temp [Collection [collection]
+                      Card       [card       {:collection_id (u/get-id collection)}]]
+  "You don't have permissions to do that."
+  ((user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:name "Number of Blueberries Consumed Per Month"}))
+
+;; Make sure that we can't change the `collection_id` of a Card if we don't have write permissions for the new collection
+(tu/expect-with-temp [Collection [original-collection]
+                      Collection [new-collection]
+                      Card       [card                {:collection_id (u/get-id original-collection)}]]
+  "You don't have permissions to do that."
+  (do
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) original-collection)
+    ((user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)})))
+
+;; Make sure that we can't change the `collection_id` of a Card if we don't have write permissions for the current collection
+(tu/expect-with-temp [Collection [original-collection]
+                      Collection [new-collection]
+                      Card       [card                {:collection_id (u/get-id original-collection)}]]
+  "You don't have permissions to do that."
+  (do
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) new-collection)
+    ((user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)})))
+
+;; But if we do have permissions for both, we should be able to change it.
+(tu/expect-with-temp [Collection [original-collection]
+                      Collection [new-collection]
+                      Card       [card                {:collection_id (u/get-id original-collection)}]]
+  (u/get-id new-collection)
+  (do
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) original-collection)
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) new-collection)
+    ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)})
+    (db/select-one-field :collection_id Card :id (u/get-id card))))
+
+
+;;; Test GET /api/card?collection= -- Test that we can use empty string to return Cards not in any collection
+(tu/expect-with-temp [Collection [collection]
+                      Card       [card-1     {:collection_id (u/get-id collection)}]
+                      Card       [card-2]]
+  [(u/get-id card-2)]
+  (do
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection)
+    (map :id ((user->client :rasta) :get 200 "card/" :collection ""))))
+
+;; Test GET /api/card?collection=<slug> filters by collection with slug
+(tu/expect-with-temp [Collection [collection {:name "Favorite Places"}]
+                      Card       [card-1     {:collection_id (u/get-id collection)}]
+                      Card       [card-2]]
+  [(u/get-id card-1)]
+  (do
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection)
+    (map :id ((user->client :rasta) :get 200 "card/" :collection :favorite_places))))
+
+;; Test GET /api/card?collection=<slug> should return a 404 if no such collection exists
+(expect
+  "Not found."
+  ((user->client :rasta) :get 404 "card/" :collection :some_fake_collection_slug))
+
+
+;;; ------------------------------------------------------------ Bulk Collections Update (POST /api/card/collections) ------------------------------------------------------------
+
+(defn- collection-ids [cards-or-card-ids]
+  (map :collection_id (db/select [Card :collection_id]
+                        :id [:in (map u/get-id cards-or-card-ids)])))
+
+(defn- POST-card-collections!
+  "Update the Collection of CARDS-OR-CARD-IDS via the `POST /api/card/collections` endpoint using USERNAME;
+   return the response of this API request and the latest Collection IDs from the database."
+  [username expected-status-code collection-or-collection-id-or-nil cards-or-card-ids]
+  [((user->client username) :post expected-status-code "card/collections"
+     {:collection_id (when collection-or-collection-id-or-nil
+                       (u/get-id collection-or-collection-id-or-nil))
+      :card_ids      (map u/get-id cards-or-card-ids)})
+   (collection-ids cards-or-card-ids)])
+
+;; Test that we can bulk move some Cards with no collection into a collection
+(tu/expect-with-temp [Collection [collection]
+                      Card       [card-1]
+                      Card       [card-2]]
+  [{:status "ok"}
+   [(u/get-id collection) (u/get-id collection)]]
+  (POST-card-collections! :crowberto 200 collection [card-1 card-2]))
+
+;; Test that we can bulk move some Cards from one collection to another
+(tu/expect-with-temp [Collection [old-collection]
+                      Collection [new-collection]
+                      Card       [card-1         {:collection_id (u/get-id old-collection)}]
+                      Card       [card-2         {:collection_id (u/get-id old-collection)}]]
+  [{:status "ok"}
+   [(u/get-id new-collection) (u/get-id new-collection)]]
+  (POST-card-collections! :crowberto 200 new-collection [card-1 card-2]))
+
+;; Test that we can bulk remove some Cards from a collection
+(tu/expect-with-temp [Collection [collection]
+                      Card       [card-1     {:collection_id (u/get-id collection)}]
+                      Card       [card-2     {:collection_id (u/get-id collection)}]]
+  [{:status "ok"}
+   [nil nil]]
+  (POST-card-collections! :crowberto 200 nil [card-1 card-2]))
+
+;; Check that we aren't allowed to move Cards if we don't have permissions for destination collection
+(tu/expect-with-temp [Collection [collection]
+                      Card       [card-1]
+                      Card       [card-2]]
+  ["You don't have permissions to do that."
+   [nil nil]]
+  (POST-card-collections! :rasta 403 collection [card-1 card-2]))
+
+;; Check that we aren't allowed to move Cards if we don't have permissions for source collection
+(tu/expect-with-temp [Collection [collection]
+                      Card       [card-1     {:collection_id (u/get-id collection)}]
+                      Card       [card-2     {:collection_id (u/get-id collection)}]]
+  ["You don't have permissions to do that."
+   [(u/get-id collection) (u/get-id collection)]]
+  (POST-card-collections! :rasta 403 nil [card-1 card-2]))
+
+;; Check that we aren't allowed to move Cards if we don't have permissions for the Card
+(tu/expect-with-temp [Collection [collection]
+                      Database   [database]
+                      Table      [table      {:db_id (u/get-id database)}]
+                      Card       [card-1     {:dataset_query (mbql-count-query (u/get-id database) (u/get-id table))}]
+                      Card       [card-2     {:dataset_query (mbql-count-query (u/get-id database) (u/get-id table))}]]
+  ["You don't have permissions to do that."
+   [nil nil]]
+  (do
+    (perms/revoke-permissions! (perms-group/all-users) (u/get-id database))
+    (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection)
+    (POST-card-collections! :rasta 403 collection [card-1 card-2])))
diff --git a/test/metabase/api/collection_test.clj b/test/metabase/api/collection_test.clj
new file mode 100644
index 0000000000000000000000000000000000000000..cac761a670d4041ad38042399e063400f2350cd8
--- /dev/null
+++ b/test/metabase/api/collection_test.clj
@@ -0,0 +1,104 @@
+(ns metabase.api.collection-test
+  "Tests for /api/collection endpoints."
+  (:require [expectations :refer :all]
+            [metabase.db :as db]
+            (metabase.models [card :refer [Card]]
+                             [collection :refer [Collection]]
+                             [permissions :as perms]
+                             [permissions-group :as group])
+            [metabase.test.data.users :refer [user->client]]
+            [metabase.test.util :as tu]
+            [metabase.util :as u]))
+
+;; check that we can get a basic list of collections
+(tu/expect-with-temp [Collection [collection]]
+  [(assoc (into {} collection) :can_write true)]
+  ((user->client :crowberto) :get 200 "collection"))
+
+;; check that we don't see collections if we don't have permissions for them
+(tu/expect-with-temp [Collection [collection-1 {:name "Collection 1"}]
+                      Collection [collection-2 {:name "Collection 2"}]]
+  ["Collection 1"]
+  (do
+    (perms/grant-collection-read-permissions! (group/all-users) collection-1)
+    (map :name ((user->client :rasta) :get 200 "collection"))))
+
+;; check that we don't see collections if they're archived
+(tu/expect-with-temp [Collection [collection-1 {:name "Archived Collection", :archived true}]
+                      Collection [collection-2 {:name "Regular Collection"}]]
+  ["Regular Collection"]
+  (do
+    (perms/grant-collection-read-permissions! (group/all-users) collection-1)
+    (perms/grant-collection-read-permissions! (group/all-users) collection-2)
+    (map :name ((user->client :rasta) :get 200 "collection"))))
+
+;; Check that if we pass `?archived=true` we instead see archived cards
+(tu/expect-with-temp [Collection [collection-1 {:name "Archived Collection", :archived true}]
+                      Collection [collection-2 {:name "Regular Collection"}]]
+  ["Archived Collection"]
+  (do
+    (perms/grant-collection-read-permissions! (group/all-users) collection-1)
+    (perms/grant-collection-read-permissions! (group/all-users) collection-2)
+    (map :name ((user->client :rasta) :get 200 "collection" :archived :true))))
+
+;; check that we can see collection details (GET /api/collection/:id)
+(expect
+  "Coin Collection"
+  (tu/with-temp Collection [collection {:name "Coin Collection"}]
+    (perms/grant-collection-read-permissions! (group/all-users) collection)
+    (:name ((user->client :rasta) :get 200 (str "collection/" (u/get-id collection))))))
+
+;; check that collections detail properly checks permissions
+(expect
+  "You don't have permissions to do that."
+  (tu/with-temp Collection [collection]
+    ((user->client :rasta) :get 403 (str "collection/" (u/get-id collection)))))
+
+;; check that cards are returned with the collections detail endpoint
+(tu/expect-with-temp [Collection [collection]
+                      Card       [card        {:collection_id (u/get-id collection)}]]
+  (tu/obj->json->obj (assoc collection :cards [card]))
+  (tu/obj->json->obj ((user->client :crowberto) :get 200 (str "collection/" (u/get-id collection)))))
+
+;; check that collections detail doesn't return archived collections
+(expect
+  "Not found."
+  (tu/with-temp Collection [collection {:archived true}]
+    (perms/grant-collection-read-permissions! (group/all-users) collection)
+    ((user->client :rasta) :get 404 (str "collection/" (u/get-id collection)))))
+
+;; test that we can create a new collection (POST /api/collection)
+(expect
+  {:name        "Stamp Collection"
+   :slug        "stamp_collection"
+   :description nil
+   :color       "#123456"
+   :archived    false}
+  (dissoc (u/prog1 ((user->client :crowberto) :post 200 "collection"
+                    {:name "Stamp Collection", :color "#123456"})
+            ;; make sure we clean up after ourselves
+            (db/cascade-delete! Collection :id (u/get-id <>)))
+          :id))
+
+;; test that non-admins aren't allowed to create a collection
+(expect
+  "You don't have permissions to do that."
+  ((user->client :rasta) :post 403 "collection"
+   {:name "Stamp Collection", :color "#123456"}))
+
+;; test that we can update a collection (PUT /api/collection/:id)
+(tu/expect-with-temp [Collection [collection]]
+  {:id          (u/get-id collection)
+   :name        "My Beautiful Collection"
+   :slug        "my_beautiful_collection"
+   :description nil
+   :color       "#ABCDEF"
+   :archived    false}
+  ((user->client :crowberto) :put 200 (str "collection/" (u/get-id collection))
+   {:name "My Beautiful Collection", :color "#ABCDEF"}))
+
+;; check that non-admins aren't allowed to update a collection
+(tu/expect-with-temp [Collection [collection]]
+  "You don't have permissions to do that."
+  ((user->client :rasta) :put 403 (str "collection/" (u/get-id collection))
+   {:name "My Beautiful Collection", :color "#ABCDEF"}))
diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj
index 48c468cbf37b156363d4631321893fcd9706a73e..3a969101e5271c23fa807ad476fda644455b7ffc 100644
--- a/test/metabase/api/dashboard_test.clj
+++ b/test/metabase/api/dashboard_test.clj
@@ -124,6 +124,7 @@
                                                        :query_type             nil
                                                        :dataset_query          {}
                                                        :visualization_settings {}
+                                                       :collection_id          nil
                                                        :archived               false}
                               :series                 []}]}
   ;; fetch a dashboard WITH a dashboard card on it
diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj
index 24d491c85f756657fcdec68657272bd3e7eb07d5..c6f2459457da028576d2a26ab9d4dea8fac4e6b8 100644
--- a/test/metabase/api/dataset_test.clj
+++ b/test/metabase/api/dataset_test.clj
@@ -64,7 +64,7 @@
                         (query checkins
                                (ql/aggregation (ql/count))))
                       (assoc :type "query")
-                      (assoc-in [:query :aggregation] [{:aggregation-type "count"}])
+                      (assoc-in [:query :aggregation] [{:aggregation-type "count", :custom-name nil}])
                       (assoc :constraints query-constraints))
     :started_at   true
     :finished_at  true
@@ -80,7 +80,7 @@
                         (query checkins
                                (ql/aggregation (ql/count))))
                       (assoc :type "query")
-                      (assoc-in [:query :aggregation] [{:aggregation-type "count"}])
+                      (assoc-in [:query :aggregation] [{:aggregation-type "count", :custom-name nil}])
                       (assoc :constraints query-constraints))
     :started_at   true
     :finished_at  true
diff --git a/test/metabase/api/table_test.clj b/test/metabase/api/table_test.clj
index 22d5efbaf65fbcf4e86dc6d362fa8fe8f2afb6c6..9665142f2149ccc33fc3c60a5f3140ea8654797e 100644
--- a/test/metabase/api/table_test.clj
+++ b/test/metabase/api/table_test.clj
@@ -326,6 +326,25 @@
             :created_at   $}))
   ((user->client :rasta) :get 200 (format "table/%d/query_metadata" (id :users))))
 
+;; Check that FK fields belonging to Tables we don't have permissions for don't come back as hydrated `:target`(#3867)
+(expect
+  #{{:name "id", :target false}
+    {:name "fk", :target false}}
+  ;; create a temp DB with two tables; table-2 has an FK to table-1
+  (tu/with-temp* [Database [db]
+                  Table    [table-1    {:db_id (u/get-id db)}]
+                  Table    [table-2    {:db_id (u/get-id db)}]
+                  Field    [table-1-id {:table_id (u/get-id table-1), :name "id", :base_type :type/Integer, :special_type :type/PK}]
+                  Field    [table-2-id {:table_id (u/get-id table-2), :name "id", :base_type :type/Integer, :special_type :type/PK}]
+                  Field    [table-2-fk {:table_id (u/get-id table-2), :name "fk", :base_type :type/Integer, :special_type :type/FK, :fk_target_field_id (u/get-id table-1-id)}]]
+    ;; grant permissions only to table-2
+    (perms/revoke-permissions! (perms-group/all-users) (u/get-id db))
+    (perms/grant-permissions! (perms-group/all-users) (u/get-id db) (:schema table-2) (u/get-id table-2))
+    ;; metadata for table-2 should show all fields for table-2, but the FK target info shouldn't be hydrated
+    (set (for [field (:fields ((user->client :rasta) :get 200 (format "table/%d/query_metadata" (u/get-id table-2))))]
+           (-> (select-keys field [:name :target])
+               (update :target boolean))))))
+
 
 ;; ## PUT /api/table/:id
 (tu/expect-with-temp [Table [table {:rows 15}]]
diff --git a/test/metabase/driver/druid_test.clj b/test/metabase/driver/druid_test.clj
index b343ca447ad16e7499ed07e8957187b09aa05c05..58d803d0cbda2d2ae1aee8e50f1183a504194183 100644
--- a/test/metabase/driver/druid_test.clj
+++ b/test/metabase/driver/druid_test.clj
@@ -1,13 +1,15 @@
 (ns metabase.driver.druid-test
   (:require [cheshire.core :as json]
             [expectations :refer :all]
+            [metabase.models.metric :refer [Metric]]
             [metabase.query-processor :as qp]
             [metabase.query-processor.expand :as ql]
-            [metabase.query-processor-test :refer [rows]]
+            [metabase.query-processor-test :refer [rows rows+column-names]]
             [metabase.test.data :as data]
             [metabase.test.data.datasets :as datasets, :refer [expect-with-engine]]
+            [metabase.test.util :as tu]
             [metabase.timeseries-query-processor-test :as timeseries-qp-test]
-            [metabase.query :as q]))
+            [metabase.util :as u]))
 
 (def ^:const ^:private ^String native-query-1
   (json/generate-string
@@ -68,12 +70,15 @@
 ;;; |                                                EXPRESSION AGGREGATIONS                                                 |
 ;;; +------------------------------------------------------------------------------------------------------------------------+
 
+(defmacro ^:private druid-query {:style/indent 0} [& body]
+  `(timeseries-qp-test/with-flattened-dbdef
+     (qp/process-query {:database (data/id)
+                        :type     :query
+                        :query    (data/query ~'checkins
+                                    ~@body)})))
+
 (defmacro ^:private druid-query-returning-rows {:style/indent 0} [& body]
-  `(rows (timeseries-qp-test/with-flattened-dbdef
-           (qp/process-query {:database (data/id)
-                              :type     :query
-                              :query    (data/query ~'checkins
-                                          ~@body)}))))
+  `(rows (druid-query ~@body)))
 
 ;; sum, *
 (expect-with-engine :druid
@@ -192,3 +197,53 @@
   (druid-query-returning-rows
     (ql/aggregation (ql/+ 1 (ql/count)))
     (ql/breakout $venue_price)))
+
+;; aggregation with math inside the aggregation :scream_cat:
+(expect-with-engine :druid
+  [["1"  442.0]
+   ["2" 1845.0]
+   ["3"  460.0]
+   ["4"  245.0]]
+  (druid-query-returning-rows
+    (ql/aggregation (ql/sum (ql/+ $venue_price 1)))
+    (ql/breakout $venue_price)))
+
+;; check that we can name an expression aggregation w/ aggregation at top-level
+(expect-with-engine :druid
+  {:rows    [["1"  442.0]
+             ["2" 1845.0]
+             ["3"  460.0]
+             ["4"  245.0]]
+   :columns ["venue_price"
+             "New Price"]}
+  (rows+column-names
+    (druid-query
+      (ql/aggregation (ql/named (ql/sum (ql/+ $venue_price 1)) "New Price"))
+      (ql/breakout $venue_price))))
+
+;; check that we can name an expression aggregation w/ expression at top-level
+(expect-with-engine :druid
+  {:rows    [["1"  180.0]
+             ["2" 1189.0]
+             ["3"  304.0]
+             ["4"  155.0]]
+   :columns ["venue_price" "Sum-41"]}
+  (rows+column-names
+    (druid-query
+      (ql/aggregation (ql/named (ql/- (ql/sum $venue_price) 41) "Sum-41"))
+      (ql/breakout $venue_price))))
+
+;; check that we can handle METRICS (ick) inside expression aggregation clauses
+(expect-with-engine :druid
+  [["2" 1231.0]
+   ["3"  346.0]
+   ["4" 197.0]]
+  (timeseries-qp-test/with-flattened-dbdef
+    (tu/with-temp Metric [metric {:definition {:aggregation [:sum [:field-id (data/id :checkins :venue_price)]]
+                                               :filter      [:> [:field-id (data/id :checkins :venue_price)] 1]}}]
+      (rows (qp/process-query
+              {:database (data/id)
+               :type     :query
+               :query    {:source-table (data/id :checkins)
+                          :aggregation  [:+ ["METRIC" (u/get-id metric)] 1]
+                          :breakout     [(ql/breakout (ql/field-id (data/id :checkins :venue_price)))]}})))))
diff --git a/test/metabase/events/activity_feed_test.clj b/test/metabase/events/activity_feed_test.clj
index 70231b803d729694b16aadcfdefc0775b40c2a65..0a541ffe77216282fff7e8273da2267b66a1746a 100644
--- a/test/metabase/events/activity_feed_test.clj
+++ b/test/metabase/events/activity_feed_test.clj
@@ -271,7 +271,7 @@
    :model       "segment"
    :model_id    (:id segment)
    :database_id (id)
-   :table_id    (id :venues)
+   :table_id    (id :checkins)
    :details     {:name        (:name segment)
                  :description (:description segment)}}
   (with-temp-activities
@@ -288,7 +288,7 @@
    :model       "segment"
    :model_id    (:id segment)
    :database_id (id)
-   :table_id    (id :venues)
+   :table_id    (id :checkins)
    :details     {:name             (:name segment)
                  :description      (:description segment)
                  :revision_message "update this mofo"}}
@@ -310,7 +310,7 @@
    :model       "segment"
    :model_id    (:id segment)
    :database_id (id)
-   :table_id    (id :venues)
+   :table_id    (id :checkins)
    :details     {:name             (:name segment)
                  :description      (:description segment)
                  :revision_message "deleted"}}
diff --git a/test/metabase/events/revision_test.clj b/test/metabase/events/revision_test.clj
index 090ed0ba97d35a67f460c699db3ae6ebf66b1957..cb5f90cd215c140b0557516b923ecf75f59c5ab1 100644
--- a/test/metabase/events/revision_test.clj
+++ b/test/metabase/events/revision_test.clj
@@ -37,6 +37,7 @@
    :id                     (:id card)
    :display                "table"
    :visualization_settings {}
+   :collection_id          nil
    :archived               false})
 
 (defn- dashboard->revision-object [dashboard]
diff --git a/test/metabase/models/collection_test.clj b/test/metabase/models/collection_test.clj
new file mode 100644
index 0000000000000000000000000000000000000000..f0255c1fd0d2b032eb0e0cf468a582217ed35e0e
--- /dev/null
+++ b/test/metabase/models/collection_test.clj
@@ -0,0 +1,75 @@
+(ns metabase.models.collection-test
+  (:require [expectations :refer :all]
+            [metabase.db :as db]
+            (metabase.models [card :refer [Card]]
+                             [collection :refer [Collection]])
+            [metabase.test.util :as tu]
+            [metabase.util :as u]))
+
+;; test that we can create a new Collection with valid inputs
+(expect
+  {:name        "My Favorite Cards"
+   :slug        "my_favorite_cards"
+   :description nil
+   :color       "#ABCDEF"
+   :archived    false}
+  (tu/with-temp Collection [collection {:name "My Favorite Cards", :color "#ABCDEF"}]
+    (dissoc collection :id)))
+
+;; check that the color is validated
+(expect Exception (db/insert! Collection {:name "My Favorite Cards"}))                    ; missing color
+(expect Exception (db/insert! Collection {:name "My Favorite Cards", :color "#ABC"}))     ; too short
+(expect Exception (db/insert! Collection {:name "My Favorite Cards", :color "#BCDEFG"}))  ; invalid chars
+(expect Exception (db/insert! Collection {:name "My Favorite Cards", :color "#ABCDEFF"})) ; too long
+(expect Exception (db/insert! Collection {:name "My Favorite Cards", :color "ABCDEF"}))   ; missing hash prefix
+
+;; double-check that `with-temp-defaults` are working correctly for Collection
+(expect
+  :ok
+  (tu/with-temp* [Collection [_]]
+    :ok))
+
+;; test that duplicate names aren't allowed
+(expect
+  Exception
+  (tu/with-temp* [Collection [_ {:name "My Favorite Cards"}]
+                  Collection [_ {:name "My Favorite Cards"}]]
+    :ok))
+
+;; things with different names that would cause the same slug shouldn't be allowed either
+(expect
+  Exception
+  (tu/with-temp* [Collection [_ {:name "My Favorite Cards"}]
+                  Collection [_ {:name "my_favorite Cards"}]]
+    :ok))
+
+;; check that archiving a collection archives its cards as well
+(expect
+  true
+  (tu/with-temp* [Collection [collection]
+                  Card       [card       {:collection_id (u/get-id collection)}]]
+    (db/update! Collection (u/get-id collection)
+      :archived true)
+    (db/select-one-field :archived Card :id (u/get-id card))))
+
+;; check that unarchiving a collection unarchives its cards as well
+(expect
+  false
+  (tu/with-temp* [Collection [collection {:archived true}]
+                  Card       [card       {:collection_id (u/get-id collection), :archived true}]]
+    (db/update! Collection (u/get-id collection)
+      :archived false)
+    (db/select-one-field :archived Card :id (u/get-id card))))
+
+;; check that collections' names cannot be blank
+(expect
+  Exception
+  (tu/with-temp Collection [collection {:name ""}]
+    collection))
+
+;; check we can't change the name of a Collection to a blank string
+(expect
+  Exception
+  (tu/with-temp Collection [collection]
+    (db/update! Collection (u/get-id collection)
+      :name "")))
diff --git a/test/metabase/models/permissions_test.clj b/test/metabase/models/permissions_test.clj
index c6ad55f07388e9c3d009d1fefbaf1efb2bf97a3b..b62509a1b57552087b1c609c43ed274191bad0f9 100644
--- a/test/metabase/models/permissions_test.clj
+++ b/test/metabase/models/permissions_test.clj
@@ -1,6 +1,10 @@
 (ns metabase.models.permissions-test
   (:require [expectations :refer :all]
-            [metabase.models.permissions :as perms]))
+            (metabase.models [permissions :as perms]
+                             [permissions-group :refer [PermissionsGroup]])
+            [metabase.test.data :as data]
+            [metabase.test.util :as tu]
+            [metabase.util :as u]))
 
 
 ;;; ------------------------------------------------------------ valid-object-path? ------------------------------------------------------------
@@ -502,3 +506,19 @@
 ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
 ;;; |                                                                 TODO - Permissions Graph Tests                                                                 |
 ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
+
+(defn- test-data-graph [group]
+  (get-in (perms/graph) [:groups (u/get-id group) (data/id) :schemas "PUBLIC"]))
+
+;; Test that setting partial permissions for a table retains permissions for other tables -- #3888
+(expect
+  [{(data/id :categories) :none, (data/id :checkins) :none, (data/id :users) :none, (data/id :venues) :all}
+   {(data/id :categories) :all,  (data/id :checkins) :none, (data/id :users) :none, (data/id :venues) :all}]
+  (tu/with-temp PermissionsGroup [group]
+    ;; first, graph permissions only for VENUES
+    (perms/grant-permissions! group (perms/object-path (data/id) "PUBLIC" (data/id :venues)))
+    [(test-data-graph group)
+     ;; next, grant permissions via `update-graph!` for CATEGORIES as well. Make sure permissions for VENUES are retained (#3888)
+     (do
+       (perms/update-graph! [(u/get-id group) (data/id) :schemas "PUBLIC" (data/id :categories)] :all)
+       (test-data-graph group))]))
diff --git a/test/metabase/permissions_collection_test.clj b/test/metabase/permissions_collection_test.clj
new file mode 100644
index 0000000000000000000000000000000000000000..a37c7535fb53f454fc81b8f5621a2286c996eaaf
--- /dev/null
+++ b/test/metabase/permissions_collection_test.clj
@@ -0,0 +1,49 @@
+(ns metabase.permissions-collection-test
+  "A test suite for permissions `Collections`. Reüses functions from `metabase.permissions-test`."
+  (:require  [expectations :refer :all]
+             [metabase.db :as db]
+             (metabase.models [card :refer [Card]]
+                              [collection :refer [Collection]]
+                              [permissions :as permissions]
+                              [permissions-group :as group])
+             [metabase.permissions-test :as perms-test]
+             [metabase.test.data.users :as test-users]
+             [metabase.test.util :as tu]
+             [metabase.util :as u]))
+
+(defn- card []
+  @(resolve 'metabase.permissions-test/*card:db2-count-of-venues*))
+
+(defn- can-run-query? [username]
+  (let [response ((test-users/user->client username) :post (format "card/%d/query" (u/get-id (card))))]
+    (not= response "You don't have permissions to do that.")))
+
+(defn- set-card-collection! [collection-or-id]
+  (db/update! Card (u/get-id (card))
+    :collection_id (u/get-id collection-or-id)))
+
+
+;; if a card is in no collection but we have data permissions, we should be able to run it
+(perms-test/expect-with-test-data
+  true
+  (can-run-query? :crowberto))
+
+;; if a card is in no collection and we don't have data permissions, we should not be able to run it
+(perms-test/expect-with-test-data
+  false
+  (can-run-query? :rasta))
+
+;; if a card is in a collection and we don't have permissions for that collection, we shouldn't be able to run it
+(perms-test/expect-with-test-data
+  false
+  (tu/with-temp Collection [collection]
+    (set-card-collection! collection)
+    (can-run-query? :rasta)))
+
+;; if a card is in a collection and we have permissions for that collection, we should be able to run it
+(perms-test/expect-with-test-data
+  true
+  (tu/with-temp Collection [collection]
+    (set-card-collection! collection)
+    (permissions/grant-collection-read-permissions! (group/all-users) collection)
+    (can-run-query? :rasta)))
diff --git a/test/metabase/permissions_test.clj b/test/metabase/permissions_test.clj
index 6855306c522ebdf5e86d2a9a55a391cdb5d18827..a50131a1b7cf92695c4402e206b040591107191a 100644
--- a/test/metabase/permissions_test.clj
+++ b/test/metabase/permissions_test.clj
@@ -23,8 +23,7 @@
             [metabase.test.data :as data]
             [metabase.test.data.users :as test-users]
             [metabase.test.util :as tu]
-            [metabase.util :as u]
-            [clj-time.core :as t]))
+            [metabase.util :as u]))
 
 ;; 3 users:
 ;; crowberto, member of Admin, All Users
@@ -369,7 +368,7 @@
 ;;; ------------------------------------------------------------ with everything! ------------------------------------------------------------
 
 
-(defn- -do-with-test-data [f]
+(defn -do-with-test-data [f]
   (((comp with-ops-group
           with-db-2
           with-db-1
@@ -379,11 +378,11 @@
           with-metrics
           with-segments) f)))
 
-(defmacro ^:private with-test-data {:style/indent 0} [& body]
+(defmacro with-test-data {:style/indent 0} [& body]
   `(-do-with-test-data (fn []
                          ~@body)))
 
-(defmacro ^:private expect-with-test-data {:style/indent 0} [expected actual]
+(defmacro expect-with-test-data {:style/indent 0} [expected actual]
   `(expect
      ~expected
      (with-test-data
@@ -470,7 +469,7 @@
 ;; Only Admin should be able to ask SQL questions against DB 2. Error message is slightly different for Rasta & Lucky because Rasta has no permissions whatsoever for DB 2 while Lucky has partial perms
 (expect-with-test-data [[100]] (sql-query :crowberto *db2*))
 (expect-with-test-data "You don't have permissions to do that." (sql-query :rasta *db2*))
-(expect-with-test-data "You do not have permissions to run new native queries against database 'DB Two'." (sql-query :lucky *db2*))
+(expect-with-test-data #"You do not have read permissions for /db/\d+/native/\." (sql-query :lucky *db2*))
 
 
 ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+
diff --git a/test/metabase/query_processor/expand_resolve_test.clj b/test/metabase/query_processor/expand_resolve_test.clj
index 8b7381575bf50db5fdb41bba0b15da97757df89c..cb381f32c1628d74451e7952b93522aee0c199ef 100644
--- a/test/metabase/query_processor/expand_resolve_test.clj
+++ b/test/metabase/query_processor/expand_resolve_test.clj
@@ -222,9 +222,10 @@
     :type     :query
     :query    {:source-table (id :checkins)
                :aggregation  [{:aggregation-type :sum
-                                :field            {:field-id      (id :venues :price)
-                                                   :fk-field-id   (id :checkins :venue_id)
-                                                   :datetime-unit nil}}]
+                               :custom-name      nil
+                               :field            {:field-id      (id :venues :price)
+                                                  :fk-field-id   (id :checkins :venue_id)
+                                                  :datetime-unit nil}}]
                :breakout     [{:field-id      (id :checkins :date)
                                :fk-field-id   nil
                                :datetime-unit :day-of-week}]}}
@@ -235,20 +236,21 @@
                                   :name   "CHECKINS"
                                   :id     (id :checkins)}
                    :aggregation  [{:aggregation-type :sum
-                                    :field            {:description        nil
-                                                       :base-type          :type/Integer
-                                                       :parent             nil
-                                                       :table-id           (id :venues)
-                                                       :special-type       :type/Category
-                                                       :field-name         "PRICE"
-                                                       :field-display-name "Price"
-                                                       :parent-id          nil
-                                                       :visibility-type    :normal
-                                                       :position           nil
-                                                       :field-id           (id :venues :price)
-                                                       :fk-field-id        (id :checkins :venue_id)
-                                                       :table-name         "VENUES__via__VENUE_ID"
-                                                       :schema-name        nil}}]
+                                   :custom-name      nil
+                                   :field            {:description        nil
+                                                      :base-type          :type/Integer
+                                                      :parent             nil
+                                                      :table-id           (id :venues)
+                                                      :special-type       :type/Category
+                                                      :field-name         "PRICE"
+                                                      :field-display-name "Price"
+                                                      :parent-id          nil
+                                                      :visibility-type    :normal
+                                                      :position           nil
+                                                      :field-id           (id :venues :price)
+                                                      :fk-field-id        (id :checkins :venue_id)
+                                                      :table-name         "VENUES__via__VENUE_ID"
+                                                      :schema-name        nil}}]
                    :breakout     [{:field {:description        nil
                                            :base-type          :type/Date
                                            :parent             nil
@@ -275,7 +277,7 @@
     :fk-field-ids #{(id :checkins :venue_id)}
     :table-ids    #{(id :venues) (id :checkins)}}]
   (let [expanded-form (ql/expand (wrap-inner-query (query checkins
-                                                        (ql/aggregation (ql/sum $venue_id->venues.price))
-                                                        (ql/breakout (ql/datetime-field $checkins.date :day-of-week)))))]
+                                                     (ql/aggregation (ql/sum $venue_id->venues.price))
+                                                     (ql/breakout (ql/datetime-field $checkins.date :day-of-week)))))]
     (mapv obj->map [expanded-form
                     (resolve/resolve expanded-form)])))
diff --git a/test/metabase/query_processor/macros_test.clj b/test/metabase/query_processor/macros_test.clj
index 8cc3bf487dbd0a7dcf0daff9ca7ad809ba6434a2..9b0ca09ed012eafb9d474a4595117aca0eabae1e 100644
--- a/test/metabase/query_processor/macros_test.clj
+++ b/test/metabase/query_processor/macros_test.clj
@@ -4,9 +4,15 @@
             [metabase.models.metric :refer [Metric]]
             [metabase.models.segment :refer [Segment]]
             [metabase.models.table :refer [Table]]
-            [metabase.query-processor.macros :refer :all]
-            [metabase.test.data.users :refer :all]
-            [metabase.test.util :as tu]))
+            [metabase.query-processor :as qp]
+            (metabase.query-processor [expand :as ql]
+                                      [macros :refer :all])
+            [metabase.query-processor-test :refer :all]
+            [metabase.test.data :as data]
+            (metabase.test.data [datasets :as datasets]
+                                [users :refer :all])
+            [metabase.test.util :as tu]
+            [metabase.util :as u]))
 
 ;; expand-macros
 
@@ -14,7 +20,7 @@
 (expect
   {:database 1
    :type     :query
-   :query    {:aggregation [["rows"]]
+   :query    {:aggregation ["rows"]
               :filter      ["AND" [">" 4 1]]
               :breakout    [17]}}
   (expand-macros {:database 1
@@ -27,8 +33,10 @@
 (expect
   {:database 1
    :type     :query
-   :query    {:aggregation [["rows"]]
-              :filter      ["AND" ["AND" ["=" 5 "abc"]] ["OR" ["AND" ["IS_NULL" 7]] [">" 4 1]]]
+   :query    {:aggregation ["rows"]
+              :filter      ["AND" ["AND" ["=" 5 "abc"]]
+                                  ["OR" ["AND" ["IS_NULL" 7]]
+                                        [">" 4 1]]]
               :breakout    [17]}}
   (tu/with-temp* [Database [{database-id :id}]
                   Table    [{table-id :id}     {:db_id database-id}]
@@ -46,8 +54,9 @@
 (expect
   {:database 1
    :type     :query
-   :query    {:aggregation [["count"]]
-              :filter      ["AND" ["AND" [">" 4 1]] ["AND" ["=" 5 "abc"]]]
+   :query    {:aggregation ["count"]
+              :filter      ["AND" ["AND" [">" 4 1]]
+                                  ["AND" ["=" 5 "abc"]]]
               :breakout    [17]
               :order_by    [[1 "ASC"]]}}
   (tu/with-temp* [Database [{database-id :id}]
@@ -66,7 +75,7 @@
 (expect
   {:database 1
    :type     :query
-   :query    {:aggregation [["count"]]
+   :query    {:aggregation ["count"]
               :filter      ["AND" ["=" 5 "abc"]]
               :breakout    [17]
               :order_by    [[1 "ASC"]]}}
@@ -86,7 +95,7 @@
 (expect
   {:database 1
    :type     :query
-   :query    {:aggregation [["count"]]
+   :query    {:aggregation ["count"]
               :filter      ["AND" ["=" 5 "abc"]]
               :breakout    [17]
               :order_by    [[1 "ASC"]]}}
@@ -105,7 +114,7 @@
 (expect
   {:database 1
    :type     :query
-   :query    {:aggregation [["sum" 18]]
+   :query    {:aggregation ["sum" 18]
               :filter      ["AND" ["AND" [">" 4 1] ["AND" ["IS_NULL" 7]]] ["AND" ["=" 5 "abc"] ["AND" ["BETWEEN" 9 0 25]]]]
               :breakout    [17]
               :order_by    [[1 "ASC"]]}}
@@ -124,3 +133,19 @@
                                :filter      ["AND" [">" 4 1] ["SEGMENT" segment-2-id]]
                                :breakout    [17]
                                :order_by    [[1 "ASC"]]}})))
+
+;; Check that a metric w/ multiple aggregation syntax (nested vector) still works correctly
+(datasets/expect-with-engines (engines-that-support :expression-aggregations)
+  [[2 118]
+   [3  39]
+   [4  24]]
+  (tu/with-temp Metric [metric {:table_id   (data/id :venues)
+                                :definition {:aggregation [[:sum [:field-id (data/id :venues :price)]]]
+                                             :filter      [:> [:field-id (data/id :venues :price)] 1]}}]
+    (format-rows-by [int int]
+      (rows (qp/process-query
+              {:database (data/id)
+               :type     :query
+               :query    {:source-table (data/id :venues)
+                          :aggregation  [["METRIC" (u/get-id metric)]]
+                          :breakout     [(ql/breakout (ql/field-id (data/id :venues :price)))]}})))))
diff --git a/test/metabase/query_processor/sql_parameters_test.clj b/test/metabase/query_processor/sql_parameters_test.clj
index fc6c7509c2ac776a9cc7f8c370783999b53d6409..56c7ecf8c5b95e614743df6e5d3d57b7159b8f60 100644
--- a/test/metabase/query_processor/sql_parameters_test.clj
+++ b/test/metabase/query_processor/sql_parameters_test.clj
@@ -12,7 +12,8 @@
             [metabase.test.data.datasets :as datasets]
             [metabase.test.data.generic-sql :as generic-sql]
             [metabase.test.util :as tu]
-            [metabase.test.data.generic-sql :as generic]))
+            [metabase.test.data.generic-sql :as generic]
+            [metabase.util :as u]))
 
 
 ;;; ------------------------------------------------------------ simple substitution -- {{x}} ------------------------------------------------------------
@@ -351,12 +352,15 @@
   (generic-sql/quote-name datasets/*driver* identifier))
 
 (defn- checkins-identifier []
-  (let [{table-name :name, schema :schema} (db/select-one ['Table :name :schema], :id (data/id :checkins))]
-    (str (when (seq schema)
-           (str (quote-name schema) \.))
-         (quote-name table-name))))
-
-;; as with the MBQL parameters tests redshift and crate fail for unknown reasons; disable their tests for now
+  ;; HACK ! I don't have all day to write protocol methods to make this work the "right" way so for BigQuery we will just hackily return the correct identifier here
+  (if (= datasets/*engine* :bigquery)
+    "[test_data.checkins]"
+    (let [{table-name :name, schema :schema} (db/select-one ['Table :name :schema], :id (data/id :checkins))]
+      (str (when (seq schema)
+             (str (quote-name schema) \.))
+           (quote-name table-name)))))
+
+;; as with the MBQL parameters tests Redshift and Crate fail for unknown reasons; disable their tests for now
 (def ^:private ^:const sql-parameters-engines
   (set/difference (engines-that-support :native-parameters) #{:redshift :crate}))
 
diff --git a/test/metabase/query_processor_test.clj b/test/metabase/query_processor_test.clj
index 1f1327c551fbf52aa48a9319b5f17268f49b65ea..c0fc9b26eb11338f3e78bca177f10cb82f914ae6 100644
--- a/test/metabase/query_processor_test.clj
+++ b/test/metabase/query_processor_test.clj
@@ -288,15 +288,22 @@
 
 
 (defn rows
-  "Return the result rows from query results, or throw an Exception if they're missing."
+  "Return the result rows from query RESULTS, or throw an Exception if they're missing."
   {:style/indent 0}
   [results]
-  (vec (or (-> results :data :rows)
+  (vec (or (get-in results [:data :rows])
            (println (u/pprint-to-str 'red results))
            (throw (Exception. "Error!")))))
 
+(defn rows+column-names
+  "Return the result rows and column names from query RESULTS, or throw an Exception if they're missing."
+  {:style/indent 0}
+  [results]
+  {:rows    (rows results)
+   :columns (get-in results [:data :columns])})
+
 (defn first-row
-  "Return the first row in the results of a query, or throw an Exception if they're missing."
+  "Return the first row in the RESULTS of a query, or throw an Exception if they're missing."
   {:style/indent 0}
   [results]
   (first (rows results)))
diff --git a/test/metabase/query_processor_test/expression_aggregations_test.clj b/test/metabase/query_processor_test/expression_aggregations_test.clj
index 533e5c606ccb749c263d6e091edd1b47a2de0c30..66a30e1a2721db11a17faedb286c062f3c217c2c 100644
--- a/test/metabase/query_processor_test/expression_aggregations_test.clj
+++ b/test/metabase/query_processor_test/expression_aggregations_test.clj
@@ -1,10 +1,13 @@
 (ns metabase.query-processor-test.expression-aggregations-test
   "Tests for expression aggregations."
   (:require [expectations :refer :all]
+            [metabase.models.metric :refer [Metric]]
+            [metabase.query-processor :as qp]
             [metabase.query-processor.expand :as ql]
             [metabase.query-processor-test :refer :all]
             [metabase.test.data :as data]
             [metabase.test.data.datasets :as datasets, :refer [*engine*]]
+            [metabase.test.util :as tu]
             [metabase.util :as u]))
 
 ;; sum, *
@@ -145,3 +148,92 @@
     (rows (data/run-query venues
             (ql/aggregation (ql/+ 1 (ql/count)))
             (ql/breakout $price)))))
+
+;; aggregation with math inside the aggregation :scream_cat:
+(datasets/expect-with-engines (engines-that-support :expression-aggregations)
+  [[1  44]
+   [2 177]
+   [3  52]
+   [4  30]]
+  (format-rows-by [int int]
+    (rows (data/run-query venues
+            (ql/aggregation (ql/sum (ql/+ $price 1)))
+            (ql/breakout $price)))))
+
+;; check that we can name an expression aggregation w/ aggregation at top-level
+(datasets/expect-with-engines (engines-that-support :expression-aggregations)
+  {:rows    [[1  44]
+             [2 177]
+             [3  52]
+             [4  30]]
+   :columns [(data/format-name "price")
+             (if (= *engine* :redshift) "new price" "New Price")]} ; Redshift annoyingly always lowercases column aliases
+  (format-rows-by [int int]
+    (rows+column-names (data/run-query venues
+                         (ql/aggregation (ql/named (ql/sum (ql/+ $price 1)) "New Price"))
+                         (ql/breakout $price)))))
+
+;; check that we can name an expression aggregation w/ expression at top-level
+(datasets/expect-with-engines (engines-that-support :expression-aggregations)
+  {:rows    [[1 -19]
+             [2  77]
+             [3  -2]
+             [4 -17]]
+   :columns [(data/format-name "price")
+             (if (= *engine* :redshift) "sum-41" "Sum-41")]}
+  (format-rows-by [int int]
+    (rows+column-names (data/run-query venues
+                         (ql/aggregation (ql/named (ql/- (ql/sum $price) 41) "Sum-41"))
+                         (ql/breakout $price)))))
+
+;; check that we can handle METRICS (ick) inside expression aggregation clauses
+(datasets/expect-with-engines (engines-that-support :expression-aggregations)
+  [[2 119]
+   [3  40]
+   [4  25]]
+  (tu/with-temp Metric [metric {:table_id   (data/id :venues)
+                                :definition {:aggregation [:sum [:field-id (data/id :venues :price)]]
+                                             :filter      [:> [:field-id (data/id :venues :price)] 1]}}]
+    (format-rows-by [int int]
+      (rows (qp/process-query
+              {:database (data/id)
+               :type     :query
+               :query    {:source-table (data/id :venues)
+                          :aggregation  [:+ ["METRIC" (u/get-id metric)] 1]
+                          :breakout     [(ql/breakout (ql/field-id (data/id :venues :price)))]}})))))
+
+;; check that we can handle METRICS (ick) inside a NAMED clause
+(datasets/expect-with-engines (engines-that-support :expression-aggregations)
+  {:rows    [[2 118]
+             [3  39]
+             [4  24]]
+   :columns [(data/format-name "price")
+             (if (= *engine* :redshift) "my cool metric" "My Cool Metric")]}
+  (tu/with-temp Metric [metric {:table_id   (data/id :venues)
+                                :definition {:aggregation [:sum [:field-id (data/id :venues :price)]]
+                                             :filter      [:> [:field-id (data/id :venues :price)] 1]}}]
+    (format-rows-by [int int]
+      (rows+column-names (qp/process-query
+                           {:database (data/id)
+                            :type     :query
+                            :query    {:source-table (data/id :venues)
+                                       :aggregation  [[:named ["METRIC" (u/get-id metric)] "My Cool Metric"]]
+                                       :breakout     [(ql/breakout (ql/field-id (data/id :venues :price)))]}})))))
+
+;; check that METRICS (ick) with a nested aggregation still work inside a NAMED clause
+(datasets/expect-with-engines (engines-that-support :expression-aggregations)
+  {:rows    [[2 118]
+             [3  39]
+             [4  24]]
+   :columns [(data/format-name "price")
+             (if (= *engine* :redshift) "my cool metric" "My Cool Metric")]}
+  (tu/with-temp Metric [metric {:table_id   (data/id :venues)
+                                :definition {:aggregation [[:sum [:field-id (data/id :venues :price)]]]
+                                             :filter      [:> [:field-id (data/id :venues :price)] 1]}}]
+    (format-rows-by [int int]
+      (rows+column-names (qp/process-query
+                           {:database (data/id)
+                            :type     :query
+                            :query    {:source-table (data/id :venues)
+                                       :aggregation  [[:named ["METRIC" (u/get-id metric)] "My Cool Metric"]]
+                                       :breakout     [(ql/breakout (ql/field-id (data/id :venues :price)))]}})))))
diff --git a/test/metabase/test/data.clj b/test/metabase/test/data.clj
index 5f23a1336ad52f1bd38e331d0cd3268968f0864e..7f54967b1982bcc27be41981e10a0c46bdb4b76d 100644
--- a/test/metabase/test/data.clj
+++ b/test/metabase/test/data.clj
@@ -105,6 +105,7 @@
   "Call `driver/process-query` on expanded inner QUERY, looking up the `Database` ID for the `source-table.`
 
      (run-query* (query (source-table 5) ...))"
+  {:style/indent 0}
   [query :- qi/Query]
   (qp/process-query (wrap-inner-query query)))
 
diff --git a/test/metabase/test/data/crate.clj b/test/metabase/test/data/crate.clj
index 3b1ff4b92d1952459ab4c8176d753690f690ca53..51c50552d6160c03b19d5198faed9baf4ba0dd90 100644
--- a/test/metabase/test/data/crate.clj
+++ b/test/metabase/test/data/crate.clj
@@ -51,8 +51,7 @@
       (insert! rows))))
 
 (def ^:private database->connection-details
-  (constantly {:host "localhost"
-               :port 4300}))
+  (constantly {:hosts "localhost:5200"}))
 
 (extend CrateDriver
   generic/IGenericSQLDatasetLoader
diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj
index a91f7567950d78c395b07b64a2b7df46d16489b2..9ac8a1365632114847ebc536ee49829e5b69b004 100644
--- a/test/metabase/test/util.clj
+++ b/test/metabase/test/util.clj
@@ -5,6 +5,7 @@
             [expectations :refer :all]
             [metabase.db :as db]
             (metabase.models [card :refer [Card]]
+                             [collection :refer [Collection]]
                              [dashboard :refer [Dashboard]]
                              [database :refer [Database]]
                              [field :refer [Field]]
@@ -107,6 +108,11 @@
                                 :name                   (random-name)
                                 :visualization_settings {}})})
 
+(u/strict-extend (class Collection)
+  WithTempDefaults
+  {:with-temp-defaults (fn [_] {:name  (random-name)
+                                :color "#ABCDEF"})})
+
 (u/strict-extend (class Dashboard)
   WithTempDefaults
   {:with-temp-defaults (fn [_] {:creator_id   (rasta-id)
@@ -124,7 +130,7 @@
   {:with-temp-defaults (fn [_] {:base_type :type/Text
                                 :name      (random-name)
                                 :position  1
-                                :table_id  (data/id :venues)})})
+                                :table_id  (data/id :checkins)})})
 
 (u/strict-extend (class Metric)
   WithTempDefaults
@@ -132,7 +138,7 @@
                                 :definition  {}
                                 :description "Lookin' for a blueberry"
                                 :name        "Toucans in the rainforest"
-                                :table_id    (data/id :venues)})})
+                                :table_id    (data/id :checkins)})})
 
 (u/strict-extend (class PermissionsGroup)
   WithTempDefaults
@@ -172,7 +178,7 @@
                                 :definition  {}
                                 :description "Lookin' for a blueberry"
                                 :name        "Toucans in the rainforest"
-                                :table_id    (data/id :venues)})})
+                                :table_id    (data/id :checkins)})})
 
 ;; TODO - `with-temp` doesn't return `Sessions`, probably because their ID is a string?
 
diff --git a/webpack.config.js b/webpack.config.js
index 315736b950e135e322fce097a5ad47d04a92395e..1d23350faae697331b0f42e781bcbda3002d6971 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,6 +1,8 @@
-"use strict";
 /*eslint-env node */
 
+require("babel-register");
+require("babel-polyfill");
+
 var webpack = require('webpack');
 var webpackPostcssTools = require('webpack-postcss-tools');
 
@@ -8,12 +10,14 @@ var CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin;
 var ExtractTextPlugin = require('extract-text-webpack-plugin');
 var HtmlWebpackPlugin = require('html-webpack-plugin');
 var UnusedFilesWebpackPlugin = require("unused-files-webpack-plugin").default;
-var FlowStatusWebpackPlugin = require('flow-status-webpack-plugin');
 
 var _ = require('underscore');
 var glob = require('glob');
 var fs = require('fs');
 
+var chevrotain = require("chevrotain");
+var allTokens = require("./frontend/src/metabase/lib/expressions/tokens").allTokens;
+
 function hasArg(arg) {
     var regex = new RegExp("^" + ((arg.length === 2) ? ("-\\w*"+arg[1]+"\\w*") : (arg)) + "$");
     return process.argv.filter(regex.test.bind(regex)).length > 0;
@@ -30,8 +34,8 @@ if (isWatching) {
     console.warn("Warning: in webpack watch mode you must restart webpack if you change any CSS variables or custom media queries");
 }
 
-// default NODE_ENV to production unless -d or --debug is specified
-var NODE_ENV = process.env["NODE_ENV"] || (hasArg("-d") || (hasArg("--debug")) ? "development": "production");
+// default NODE_ENV to development
+var NODE_ENV = process.env["NODE_ENV"] || "development";
 console.log("webpack env:", NODE_ENV)
 
 // Babel:
@@ -222,8 +226,7 @@ if (NODE_ENV === "hot") {
     );
 }
 
-// development environment:
-if (NODE_ENV === "development" || NODE_ENV === "hot") {
+if (NODE_ENV !== "production") {
     // replace minified files with un-minified versions
     for (var name in config.resolve.alias) {
         var minified = config.resolve.alias[name];
@@ -232,13 +235,7 @@ if (NODE_ENV === "development" || NODE_ENV === "hot") {
             config.resolve.alias[name] = unminified;
         }
     }
-}
 
-if (process.env.ENABLE_FLOW) {
-    config.plugins.push(new FlowStatusWebpackPlugin());
-}
-
-if (NODE_ENV === "hot" || isWatching) {
     // enable "cheap" source maps in hot or watch mode since re-build speed overhead is < 1 second
     config.devtool = "eval-cheap-module-source-map";
 
@@ -248,6 +245,16 @@ if (NODE_ENV === "hot" || isWatching) {
     // helps with source maps
     config.output.devtoolModuleFilenameTemplate = '[absolute-resource-path]';
     config.output.pathinfo = true;
-} else if (NODE_ENV === "production") {
+} else {
+    // this is required to ensure we don't minify Chevrotain token identifiers
+    // https://github.com/SAP/chevrotain/tree/master/examples/parser/minification
+    config.plugins.push(new webpack.optimize.UglifyJsPlugin({
+        mangle: {
+            except: allTokens.map(function(currTok) {
+                return chevrotain.tokenName(currTok);
+            })
+        }
+    }))
+
     config.devtool = "source-map";
 }
diff --git a/yarn.lock b/yarn.lock
index 96befc4141c2cc3b27c2cf9df919f54d3598b50f..e7c214409b55cbc7c6eec48389af307c22c48acf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -376,37 +376,35 @@ babel-cli@^6.11.4:
   optionalDependencies:
     chokidar "^1.0.0"
 
-babel-code-frame@^6.16.0:
-  version "6.16.0"
-  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.16.0.tgz#f90e60da0862909d3ce098733b5d3987c97cb8de"
+babel-code-frame@^6.20.0:
+  version "6.20.0"
+  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.20.0.tgz#b968f839090f9a8bc6d41938fb96cb84f7387b26"
   dependencies:
     chalk "^1.1.0"
     esutils "^2.0.2"
     js-tokens "^2.0.0"
 
-babel-core@^6.11.4, babel-core@^6.16.0, babel-core@^6.9.0:
-  version "6.17.0"
-  resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.17.0.tgz#6c4576447df479e241e58c807e4bc7da4db7f425"
+babel-core@^6.11.4, babel-core@^6.16.0, babel-core@^6.18.0, babel-core@^6.20.0, babel-core@^6.9.0:
+  version "6.20.0"
+  resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.20.0.tgz#ab0d7176d9dea434e66badadaf92237865eab1ec"
   dependencies:
-    babel-code-frame "^6.16.0"
-    babel-generator "^6.17.0"
+    babel-code-frame "^6.20.0"
+    babel-generator "^6.20.0"
     babel-helpers "^6.16.0"
     babel-messages "^6.8.0"
-    babel-register "^6.16.0"
-    babel-runtime "^6.9.1"
+    babel-register "^6.18.0"
+    babel-runtime "^6.20.0"
     babel-template "^6.16.0"
-    babel-traverse "^6.16.0"
-    babel-types "^6.16.0"
+    babel-traverse "^6.20.0"
+    babel-types "^6.20.0"
     babylon "^6.11.0"
     convert-source-map "^1.1.0"
     debug "^2.1.1"
-    json5 "^0.4.0"
+    json5 "^0.5.0"
     lodash "^4.2.0"
     minimatch "^3.0.2"
-    path-exists "^1.0.0"
     path-is-absolute "^1.0.0"
     private "^0.1.6"
-    shebang-regex "^1.0.0"
     slash "^1.0.0"
     source-map "^0.5.0"
 
@@ -420,14 +418,14 @@ babel-eslint@^6.1.2:
     lodash.assign "^4.0.0"
     lodash.pickby "^4.0.0"
 
-babel-generator@^6.17.0:
-  version "6.17.0"
-  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.17.0.tgz#b894e3808beef7800f2550635bfe024b6226cf33"
+babel-generator@^6.20.0:
+  version "6.20.0"
+  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.20.0.tgz#fee63614e0449390103b3097f3f6a118016c6766"
   dependencies:
     babel-messages "^6.8.0"
-    babel-runtime "^6.9.0"
-    babel-types "^6.16.0"
-    detect-indent "^3.0.1"
+    babel-runtime "^6.20.0"
+    babel-types "^6.20.0"
+    detect-indent "^4.0.0"
     jsesc "^1.3.0"
     lodash "^4.2.0"
     source-map "^0.5.0"
@@ -1054,7 +1052,26 @@ babel-register@^6.11.6, babel-register@^6.16.0:
     path-exists "^1.0.0"
     source-map-support "^0.4.2"
 
-babel-runtime@6.x.x, babel-runtime@^6.0.0, babel-runtime@^6.11.6, babel-runtime@^6.2.0, babel-runtime@^6.5.0, babel-runtime@^6.9.0, babel-runtime@^6.9.1, babel-runtime@^6.9.2:
+babel-register@^6.18.0:
+  version "6.18.0"
+  resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.18.0.tgz#892e2e03865078dd90ad2c715111ec4449b32a68"
+  dependencies:
+    babel-core "^6.18.0"
+    babel-runtime "^6.11.6"
+    core-js "^2.4.0"
+    home-or-tmp "^2.0.0"
+    lodash "^4.2.0"
+    mkdirp "^0.5.1"
+    source-map-support "^0.4.2"
+
+babel-runtime@6.x.x, babel-runtime@^6.0.0, babel-runtime@^6.2.0, babel-runtime@^6.20.0, babel-runtime@^6.5.0:
+  version "6.20.0"
+  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.20.0.tgz#87300bdcf4cd770f09bf0048c64204e17806d16f"
+  dependencies:
+    core-js "^2.4.0"
+    regenerator-runtime "^0.10.0"
+
+babel-runtime@^6.11.6, babel-runtime@^6.9.0, babel-runtime@^6.9.1, babel-runtime@^6.9.2:
   version "6.11.6"
   resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.11.6.tgz#6db707fef2d49c49bfa3cb64efdb436b518b8222"
   dependencies:
@@ -1071,25 +1088,25 @@ babel-template@^6.14.0, babel-template@^6.15.0, babel-template@^6.16.0, babel-te
     babylon "^6.11.0"
     lodash "^4.2.0"
 
-babel-traverse@^6.0.20, babel-traverse@^6.14.0, babel-traverse@^6.15.0, babel-traverse@^6.16.0, babel-traverse@^6.8.0:
-  version "6.16.0"
-  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.16.0.tgz#fba85ae1fd4d107de9ce003149cc57f53bef0c4f"
+babel-traverse@^6.0.20, babel-traverse@^6.14.0, babel-traverse@^6.15.0, babel-traverse@^6.16.0, babel-traverse@^6.20.0, babel-traverse@^6.8.0:
+  version "6.20.0"
+  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.20.0.tgz#5378d1a743e3d856e6a52289994100bbdfd9872a"
   dependencies:
-    babel-code-frame "^6.16.0"
+    babel-code-frame "^6.20.0"
     babel-messages "^6.8.0"
-    babel-runtime "^6.9.0"
-    babel-types "^6.16.0"
+    babel-runtime "^6.20.0"
+    babel-types "^6.20.0"
     babylon "^6.11.0"
     debug "^2.2.0"
-    globals "^8.3.0"
+    globals "^9.0.0"
     invariant "^2.2.0"
     lodash "^4.2.0"
 
-babel-types@^6.0.19, babel-types@^6.13.0, babel-types@^6.14.0, babel-types@^6.15.0, babel-types@^6.16.0, babel-types@^6.8.0, babel-types@^6.9.0:
-  version "6.16.0"
-  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.16.0.tgz#71cca1dbe5337766225c5c193071e8ebcbcffcfe"
+babel-types@^6.0.19, babel-types@^6.13.0, babel-types@^6.14.0, babel-types@^6.15.0, babel-types@^6.16.0, babel-types@^6.20.0, babel-types@^6.8.0, babel-types@^6.9.0:
+  version "6.20.0"
+  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.20.0.tgz#3869ecb98459533b37df809886b3f7f3b08d2baa"
   dependencies:
-    babel-runtime "^6.9.1"
+    babel-runtime "^6.20.0"
     esutils "^2.0.2"
     lodash "^4.2.0"
     to-fast-properties "^1.0.1"
@@ -1427,6 +1444,10 @@ change-emitter@^0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.2.tgz#6b88ca4d5d864e516f913421b11899a860aee8db"
 
+chevrotain@^0.20.0:
+  version "0.20.0"
+  resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-0.20.0.tgz#b814daf9e15c134bf421ff54b429b818c6648a9e"
+
 chokidar@^1.0.0, chokidar@^1.4.1:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.6.0.tgz#90c32ad4802901d7713de532dc284e96a63ad058"
@@ -2006,13 +2027,11 @@ detect-file@^0.1.0:
   dependencies:
     fs-exists-sync "^0.1.0"
 
-detect-indent@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-3.0.1.tgz#9dc5e5ddbceef8325764b9451b02bc6d54084f75"
+detect-indent@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
   dependencies:
-    get-stdin "^4.0.1"
-    minimist "^1.1.0"
-    repeating "^1.1.0"
+    repeating "^2.0.0"
 
 di@^0.0.1:
   version "0.0.1"
@@ -2368,6 +2387,10 @@ eslint-plugin-flowtype:
   dependencies:
     lodash "^4.15.0"
 
+eslint-plugin-jasmine@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jasmine/-/eslint-plugin-jasmine-2.2.0.tgz#7135879383c39a667c721d302b9f20f0389543de"
+
 eslint-plugin-react@^6.3.0:
   version "6.4.1"
   resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-6.4.1.tgz#7d1aade747db15892f71eee1fea4addf97bcfa2b"
@@ -2742,12 +2765,6 @@ flow-bin@^0.32.0:
   version "0.32.0"
   resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.32.0.tgz#a1d69d153a07b0a9cd4a633d13bf746d4ace5730"
 
-flow-status-webpack-plugin@^0.1.4:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/flow-status-webpack-plugin/-/flow-status-webpack-plugin-0.1.7.tgz#425a6cdf3763a8aeef3dff203e5127b919bd104a"
-  dependencies:
-    shelljs "^0.6.0"
-
 flux-standard-action@^0.6.0, flux-standard-action@^0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/flux-standard-action/-/flux-standard-action-0.6.1.tgz#6f34211b94834ea1c3cc30f4e7afad3d0fbf71a2"
@@ -2968,11 +2985,7 @@ global-prefix@^0.1.4:
     osenv "^0.1.3"
     which "^1.2.10"
 
-globals@^8.3.0:
-  version "8.18.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-8.18.0.tgz#93d4a62bdcac38cfafafc47d6b034768cb0ffcb4"
-
-globals@^9.2.0:
+globals@^9.0.0, globals@^9.2.0:
   version "9.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.12.0.tgz#992ce90828c3a55fa8f16fada177adb64664cf9d"
 
@@ -3129,6 +3142,13 @@ home-or-tmp@^1.0.0:
     os-tmpdir "^1.0.1"
     user-home "^1.1.1"
 
+home-or-tmp@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
+  dependencies:
+    os-homedir "^1.0.0"
+    os-tmpdir "^1.0.1"
+
 hosted-git-info@^2.1.4:
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b"
@@ -3751,10 +3771,6 @@ json3@3.3.2, json3@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1"
 
-json5@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-0.4.0.tgz#054352e4c4c80c86c0923877d449de176a732c8d"
-
 json5@^0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.0.tgz#9b20715b026cbe3778fd769edccd822d8332a5b2"
@@ -4877,6 +4893,10 @@ pbkdf2-compat@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz#b6e0c8fa99494d94e0511575802a59a5c142f288"
 
+performance-now@^0.2.0, performance-now@~0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
+
 pify@^2.0.0, pify@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -5502,6 +5522,12 @@ querystringify@0.0.x:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c"
 
+raf@^3.1.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/raf/-/raf-3.3.0.tgz#93845eeffc773f8129039f677f80a36044eee2c3"
+  dependencies:
+    performance-now "~0.2.0"
+
 randomatic@^1.1.3:
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.5.tgz#5e9ef5f2d573c67bd2b8124ae90b5156e457840b"
@@ -5553,17 +5579,15 @@ react-ansi-style@^1.0.0:
     ansi-style-parser "^1.0.1"
     js-managed-css "^1.4.1"
 
+react-collapse@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/react-collapse/-/react-collapse-2.3.3.tgz#68c70f1fceeaf375e3599b95710cc51e5dd339a3"
+
 "react-dom@^0.14.0 || ^15.0.0", react-dom@^15.2.1:
   version "15.3.2"
   resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.2.tgz#c46b0aa5380d7b838e7a59c4a7beff2ed315531f"
 
-react-draggable@^2.1.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-2.2.2.tgz#80932da5ad81795bdfd141e846d7a9309680e270"
-  dependencies:
-    classnames "^2.2.5"
-
-react-draggable@^2.2.3:
+react-draggable@^2.1.0, react-draggable@^2.2.3:
   version "2.2.3"
   resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-2.2.3.tgz#17628cb8aaefed639d38e0021b978a685d80b08b"
   dependencies:
@@ -5577,6 +5601,10 @@ react-fuzzy@^0.2.3:
     classnames "^2.2.3"
     fuse.js "^2.2.0"
 
+react-height@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/react-height/-/react-height-2.1.1.tgz#14c27a8e3a7e43a17dd128c8f3ee00f03b0001c2"
+
 react-hot-api@^0.4.5:
   version "0.4.7"
   resolved "https://registry.yarnpkg.com/react-hot-api/-/react-hot-api-0.4.7.tgz#a7e22a56d252e11abd9366b61264cf4492c58171"
@@ -5618,6 +5646,13 @@ react-modal@^1.2.1:
     exenv "1.2.0"
     lodash.assign "^3.2.0"
 
+react-motion:
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.4.5.tgz#ecc42f692fec9b2de4c92f85e26375071f779b76"
+  dependencies:
+    performance-now "^0.2.0"
+    raf "^3.1.0"
+
 react-redux@^4.4.5:
   version "4.4.5"
   resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-4.4.5.tgz#f509a2981be2252d10c629ef7c559347a4aec457"
@@ -5868,6 +5903,10 @@ regenerate@^1.2.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.1.tgz#0300203a5d2fdcf89116dce84275d011f5903f33"
 
+regenerator-runtime@^0.10.0:
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz#257f41961ce44558b18f7814af48c17559f9faeb"
+
 regenerator-runtime@^0.9.5:
   version "0.9.5"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.9.5.tgz#403d6d40a4bdff9c330dd9392dcbb2d9a8bba1fc"
@@ -5931,12 +5970,6 @@ repeat-string@^1.5.2:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.5.4.tgz#64ec0c91e0f4b475f90d5b643651e3e6e5b6c2d5"
 
-repeating@^1.1.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/repeating/-/repeating-1.1.3.tgz#3d4114218877537494f97f77f9785fab810fa4ac"
-  dependencies:
-    is-finite "^1.0.0"
-
 repeating@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
@@ -6207,10 +6240,6 @@ shallowequal@0.2.x:
   dependencies:
     lodash.keys "^3.1.2"
 
-shebang-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
-
 shelljs@^0.6.0:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.6.1.tgz#ec6211bed1920442088fe0f70b2837232ed2c8a8"