Merge "Merge favorites and preferences UI."
diff --git a/web/dashboard/src/main/java/com/android/vts/api/UserFavoriteRestServlet.java b/web/dashboard/src/main/java/com/android/vts/api/UserFavoriteRestServlet.java
new file mode 100644
index 0000000..f863051
--- /dev/null
+++ b/web/dashboard/src/main/java/com/android/vts/api/UserFavoriteRestServlet.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2017 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you
+ * may not use this file except in compliance with the License. You may
+ * obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.android.vts.api;
+
+import com.android.vts.entity.TestEntity;
+import com.android.vts.entity.UserFavoriteEntity;
+import com.google.appengine.api.datastore.DatastoreService;
+import com.google.appengine.api.datastore.DatastoreServiceFactory;
+import com.google.appengine.api.datastore.Entity;
+import com.google.appengine.api.datastore.EntityNotFoundException;
+import com.google.appengine.api.datastore.Key;
+import com.google.appengine.api.datastore.KeyFactory;
+import com.google.appengine.api.datastore.Query;
+import com.google.appengine.api.datastore.Transaction;
+import com.google.appengine.api.datastore.Query.CompositeFilterOperator;
+import com.google.appengine.api.datastore.Query.Filter;
+import com.google.appengine.api.datastore.Query.FilterOperator;
+import com.google.appengine.api.datastore.Query.FilterPredicate;
+import com.google.appengine.api.users.User;
+import com.google.appengine.api.users.UserService;
+import com.google.appengine.api.users.UserServiceFactory;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Servlet for handling requests to add or remove subscriptions. */
+public class UserFavoriteRestServlet extends HttpServlet {
+    protected static final Logger logger =
+            Logger.getLogger(UserFavoriteRestServlet.class.getName());
+
+    /**
+     * Add a test to the user's favorites.
+     */
+    @Override
+    public void doPost(HttpServletRequest request, HttpServletResponse response)
+            throws IOException {
+        UserService userService = UserServiceFactory.getUserService();
+        User currentUser = userService.getCurrentUser();
+        DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
+
+        // Retrieve the added tests from the request.
+        String test = request.getPathInfo();
+        if (test == null) {
+            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+            return;
+        }
+        if (test.startsWith("/")) {
+            test = test.substring(1);
+        }
+        Key addedTestKey = KeyFactory.createKey(TestEntity.KIND, test);
+        // Filter the tests that exist from the set of tests to add
+        try {
+            datastore.get(addedTestKey);
+        } catch (EntityNotFoundException e) {
+            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+            return;
+        }
+
+        Filter userFilter =
+                new FilterPredicate(UserFavoriteEntity.USER, FilterOperator.EQUAL, currentUser);
+        Filter testFilter = new FilterPredicate(
+                UserFavoriteEntity.TEST_KEY, FilterOperator.EQUAL, addedTestKey);
+        Query q = new Query(UserFavoriteEntity.KIND)
+                          .setFilter(CompositeFilterOperator.and(userFilter, testFilter))
+                          .setKeysOnly();
+
+        Key favoriteKey = null;
+
+        Transaction txn = datastore.beginTransaction();
+        try {
+            for (Entity e : datastore.prepare(q).asIterable()) {
+                favoriteKey = e.getKey();
+                break;
+            }
+            if (favoriteKey == null) {
+                UserFavoriteEntity favorite = new UserFavoriteEntity(currentUser, addedTestKey);
+                Entity entity = favorite.toEntity();
+                datastore.put(entity);
+                favoriteKey = entity.getKey();
+            }
+            txn.commit();
+        } finally {
+            if (txn.isActive()) {
+                logger.log(Level.WARNING,
+                        "Transaction rollback forced for favorite creation: " + test);
+                txn.rollback();
+            }
+        }
+
+        response.setContentType("application/json");
+        PrintWriter writer = response.getWriter();
+        JsonObject json = new JsonObject();
+        json.add("key", new JsonPrimitive(KeyFactory.keyToString(favoriteKey)));
+        writer.print(new Gson().toJson(json));
+        writer.flush();
+        response.setStatus(HttpServletResponse.SC_OK);
+    }
+
+    /**
+     * Remove a test from the user's favorites.
+     */
+    @Override
+    public void doDelete(HttpServletRequest request, HttpServletResponse response)
+            throws IOException {
+        DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
+        String stringKey = request.getPathInfo();
+        if (stringKey == null) {
+            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+            return;
+        }
+        if (stringKey.startsWith("/")) {
+            stringKey = stringKey.substring(1);
+        }
+        datastore.delete(KeyFactory.stringToKey(stringKey));
+        response.setStatus(HttpServletResponse.SC_OK);
+    }
+}
diff --git a/web/dashboard/src/main/java/com/android/vts/servlet/DashboardMainServlet.java b/web/dashboard/src/main/java/com/android/vts/servlet/DashboardMainServlet.java
index a1c7132..0c2a86a 100644
--- a/web/dashboard/src/main/java/com/android/vts/servlet/DashboardMainServlet.java
+++ b/web/dashboard/src/main/java/com/android/vts/servlet/DashboardMainServlet.java
@@ -22,16 +22,16 @@
 import com.google.appengine.api.datastore.DatastoreServiceFactory;
 import com.google.appengine.api.datastore.Entity;
 import com.google.appengine.api.datastore.Key;
+import com.google.appengine.api.datastore.KeyFactory;
 import com.google.appengine.api.datastore.PropertyProjection;
 import com.google.appengine.api.datastore.Query;
-import com.google.appengine.api.datastore.Query.CompositeFilter;
-import com.google.appengine.api.datastore.Query.CompositeFilterOperator;
 import com.google.appengine.api.datastore.Query.Filter;
 import com.google.appengine.api.datastore.Query.FilterOperator;
 import com.google.appengine.api.datastore.Query.FilterPredicate;
 import com.google.appengine.api.users.User;
 import com.google.appengine.api.users.UserService;
 import com.google.appengine.api.users.UserServiceFactory;
+import com.google.gson.Gson;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -51,8 +51,6 @@
     private static final String DASHBOARD_FAVORITES_LINK = "/";
     private static final String ALL_HEADER = "All Tests";
     private static final String FAVORITES_HEADER = "Favorites";
-    private static final String NO_FAVORITES_ERROR =
-            "No subscribed tests. Click the edit button to add to favorites.";
     private static final String NO_TESTS_ERROR = "No test results available.";
     private static final String FAVORITES_BUTTON = "Show Favorites";
     private static final String ALL_BUTTON = "Show All";
@@ -71,16 +69,19 @@
     /** Helper class for displaying test entries on the main dashboard. */
     public class TestDisplay implements Comparable<TestDisplay> {
         private final Key testKey;
+        private final int passCount;
         private final int failCount;
 
         /**
          * Test display constructor.
          *
          * @param testKey The key of the test.
+         * @param passCount The number of tests passing.
          * @param failCount The number of tests failing.
          */
-        public TestDisplay(Key testKey, int failCount) {
+        public TestDisplay(Key testKey, int passCount, int failCount) {
             this.testKey = testKey;
+            this.passCount = passCount;
             this.failCount = failCount;
         }
 
@@ -94,6 +95,15 @@
         }
 
         /**
+         * Get the number of passing test cases.
+         *
+         * @return The number of passing test cases.
+         */
+        public int getPassCount() {
+            return this.passCount;
+        }
+
+        /**
          * Get the number of failing test cases.
          *
          * @return The number of failing test cases.
@@ -117,8 +127,10 @@
         DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
 
         List<TestDisplay> displayedTests = new ArrayList<>();
+        List<String> allTests = new ArrayList<>();
 
-        Map<Key, Integer> failCountMap = new HashMap<>(); // map from table name to fail count
+        Map<Key, TestDisplay> testMap = new HashMap<>(); // map from table key to TestDisplay
+        Map<String, String> subscriptionMap = new HashMap<>();
 
         boolean showAll = request.getParameter("showAll") != null;
         String header;
@@ -128,44 +140,46 @@
         String error = null;
 
         Query q = new Query(TestEntity.KIND)
+                          .addProjection(new PropertyProjection(TestEntity.PASS_COUNT, Long.class))
                           .addProjection(new PropertyProjection(TestEntity.FAIL_COUNT, Long.class));
         for (Entity test : datastore.prepare(q).asIterable()) {
             TestEntity testEntity = TestEntity.fromEntity(test);
             if (test != null) {
-                failCountMap.put(test.getKey(), testEntity.failCount);
+                TestDisplay display =
+                        new TestDisplay(test.getKey(), testEntity.passCount, testEntity.failCount);
+                testMap.put(test.getKey(), display);
+                allTests.add(test.getKey().getName());
             }
         }
 
+        if (testMap.size() == 0) {
+            error = NO_TESTS_ERROR;
+        }
+
         if (showAll) {
-            for (Key testKey : failCountMap.keySet()) {
-                TestDisplay test = new TestDisplay(testKey, failCountMap.get(testKey));
-                displayedTests.add(test);
-            }
-            if (displayedTests.size() == 0) {
-                error = NO_TESTS_ERROR;
+            for (Key testKey : testMap.keySet()) {
+                displayedTests.add(testMap.get(testKey));
             }
             header = ALL_HEADER;
             buttonLabel = FAVORITES_BUTTON;
             buttonIcon = UP_ARROW;
             buttonLink = DASHBOARD_FAVORITES_LINK;
         } else {
-            if (failCountMap.size() > 0) {
+            if (testMap.size() > 0) {
                 Filter userFilter = new FilterPredicate(
                         UserFavoriteEntity.USER, FilterOperator.EQUAL, currentUser);
-                Filter propertyFilter = new FilterPredicate(
-                        UserFavoriteEntity.TEST_KEY, FilterOperator.IN, failCountMap.keySet());
-                CompositeFilter filter = CompositeFilterOperator.and(userFilter, propertyFilter);
-                q = new Query(UserFavoriteEntity.KIND).setFilter(filter);
+                q = new Query(UserFavoriteEntity.KIND).setFilter(userFilter);
 
                 for (Entity favorite : datastore.prepare(q).asIterable()) {
                     Key testKey = (Key) favorite.getProperty(UserFavoriteEntity.TEST_KEY);
-                    TestDisplay test = new TestDisplay(testKey, failCountMap.get(testKey));
-                    displayedTests.add(test);
+                    if (!testMap.containsKey(testKey)) {
+                        continue;
+                    }
+                    displayedTests.add(testMap.get(testKey));
+                    subscriptionMap.put(
+                            testKey.getName(), KeyFactory.keyToString(favorite.getKey()));
                 }
             }
-            if (displayedTests.size() == 0) {
-                error = NO_FAVORITES_ERROR;
-            }
             header = FAVORITES_HEADER;
             buttonLabel = ALL_BUTTON;
             buttonIcon = DOWN_ARROW;
@@ -174,6 +188,8 @@
         Collections.sort(displayedTests);
 
         response.setStatus(HttpServletResponse.SC_OK);
+        request.setAttribute("allTestsJson", new Gson().toJson(allTests));
+        request.setAttribute("subscriptionMapJson", new Gson().toJson(subscriptionMap));
         request.setAttribute("testNames", displayedTests);
         request.setAttribute("headerLabel", header);
         request.setAttribute("showAll", showAll);
diff --git a/web/dashboard/src/main/java/com/android/vts/servlet/ShowPreferencesServlet.java b/web/dashboard/src/main/java/com/android/vts/servlet/ShowPreferencesServlet.java
deleted file mode 100644
index fc1db79..0000000
--- a/web/dashboard/src/main/java/com/android/vts/servlet/ShowPreferencesServlet.java
+++ /dev/null
@@ -1,202 +0,0 @@
-/*
- * Copyright (c) 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you
- * may not use this file except in compliance with the License. You may
- * obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- * implied. See the License for the specific language governing
- * permissions and limitations under the License.
- */
-
-package com.android.vts.servlet;
-
-import com.android.vts.entity.TestEntity;
-import com.android.vts.entity.UserFavoriteEntity;
-import com.google.appengine.api.datastore.DatastoreService;
-import com.google.appengine.api.datastore.DatastoreServiceFactory;
-import com.google.appengine.api.datastore.Entity;
-import com.google.appengine.api.datastore.Key;
-import com.google.appengine.api.datastore.KeyFactory;
-import com.google.appengine.api.datastore.Query;
-import com.google.appengine.api.datastore.Query.Filter;
-import com.google.appengine.api.datastore.Query.FilterOperator;
-import com.google.appengine.api.datastore.Query.FilterPredicate;
-import com.google.appengine.api.users.User;
-import com.google.appengine.api.users.UserService;
-import com.google.appengine.api.users.UserServiceFactory;
-import com.google.gson.Gson;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.logging.Level;
-import javax.servlet.RequestDispatcher;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.lang.StringUtils;
-
-/** Represents the servlet that is invoked on loading the preferences page to manage favorites. */
-public class ShowPreferencesServlet extends BaseServlet {
-    private static final String PREFERENCES_JSP = "WEB-INF/jsp/show_preferences.jsp";
-    private static final String DASHBOARD_MAIN_LINK = "/";
-
-    /** Helper class for displaying test subscriptions. */
-    public class Subscription {
-        private final String testName;
-        private final String key;
-
-        /**
-         * Test display constructor.
-         *
-         * @param testName The name of the test.
-         * @param key The websafe string serialization of the subscription key.
-         */
-        public Subscription(String testName, String key) {
-            this.testName = testName;
-            this.key = key;
-        }
-
-        /**
-         * Get the name of the test.
-         *
-         * @return The name of the test.
-         */
-        public String getTestName() {
-            return this.testName;
-        }
-
-        /**
-         * Get the subscription key.
-         *
-         * @return The subscription key.
-         */
-        public String getKey() {
-            return this.key;
-        }
-    }
-
-    @Override
-    public List<String[]> getNavbarLinks(HttpServletRequest request) {
-        List<String[]> links = new ArrayList<>();
-        Page root = Page.HOME;
-        String[] rootEntry = new String[] {root.getUrl(), root.getName()};
-        links.add(rootEntry);
-
-        Page prefs = Page.PREFERENCES;
-        String[] prefsEntry = new String[] {CURRENT_PAGE, prefs.getName()};
-        links.add(prefsEntry);
-        return links;
-    }
-
-    @Override
-    public void doGetHandler(HttpServletRequest request, HttpServletResponse response)
-            throws IOException {
-        // Get the user's information
-        UserService userService = UserServiceFactory.getUserService();
-        User currentUser = userService.getCurrentUser();
-        RequestDispatcher dispatcher = null;
-        DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
-
-        List<Subscription> subscriptions = new ArrayList<>();
-
-        // Map from TestEntity key to the UserFavoriteEntity object
-        Map<Key, Entity> subscriptionEntityMap = new HashMap<>();
-
-        // Map from test name to the subscription object
-        Map<String, Subscription> subscriptionMap = new HashMap<>();
-
-        // Query for the favorites entities matching the current user
-        Filter propertyFilter =
-                new FilterPredicate(UserFavoriteEntity.USER, FilterOperator.EQUAL, currentUser);
-        Query q = new Query(UserFavoriteEntity.KIND).setFilter(propertyFilter);
-        for (Entity favorite : datastore.prepare(q).asIterable()) {
-            UserFavoriteEntity favoriteEntity = UserFavoriteEntity.fromEntity(favorite);
-            if (favoriteEntity == null) {
-                continue;
-            }
-            subscriptionEntityMap.put(favoriteEntity.testKey, favorite);
-        }
-        if (subscriptionEntityMap.size() > 0) {
-            // Query for the tests specified by the user favorite entities
-            propertyFilter = new FilterPredicate(Entity.KEY_RESERVED_PROPERTY, FilterOperator.IN,
-                    subscriptionEntityMap.keySet());
-            q = new Query(TestEntity.KIND).setFilter(propertyFilter).setKeysOnly();
-            for (Entity test : datastore.prepare(q).asIterable()) {
-                String testName = test.getKey().getName();
-                Entity subscriptionEntity = subscriptionEntityMap.get(test.getKey());
-                Subscription sub = new Subscription(
-                        testName, KeyFactory.keyToString(subscriptionEntity.getKey()));
-                subscriptions.add(sub);
-                subscriptionMap.put(testName, sub);
-            }
-        }
-        List<String> allTests = new ArrayList<>();
-        for (Entity result : datastore.prepare(new Query(TestEntity.KIND)).asIterable()) {
-            allTests.add(result.getKey().getName());
-        }
-
-        request.setAttribute("allTestsJson", new Gson().toJson(allTests));
-        request.setAttribute("subscriptions", subscriptions);
-        request.setAttribute("subscriptionMapJson", new Gson().toJson(subscriptionMap));
-        request.setAttribute("subscriptionsJson", new Gson().toJson(subscriptions));
-
-        dispatcher = request.getRequestDispatcher(PREFERENCES_JSP);
-        try {
-            dispatcher.forward(request, response);
-        } catch (ServletException e) {
-            logger.log(Level.SEVERE, "Servlet Exception caught : ", e);
-        }
-    }
-
-    @Override
-    public void doPost(HttpServletRequest request, HttpServletResponse response)
-            throws IOException {
-        UserService userService = UserServiceFactory.getUserService();
-        User currentUser = userService.getCurrentUser();
-        DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
-
-        // Retrieve the added tests from the request.
-        String addedTestsString = request.getParameter("addedTests");
-        List<Key> addedTests = new ArrayList<>();
-        if (!StringUtils.isBlank(addedTestsString)) {
-            for (String test : addedTestsString.trim().split(",")) {
-                addedTests.add(KeyFactory.createKey(TestEntity.KIND, test));
-            }
-        }
-        if (addedTests.size() > 0) {
-            // Filter the tests that exist from the set of tests to add
-            Filter propertyFilter = new FilterPredicate(
-                    Entity.KEY_RESERVED_PROPERTY, FilterOperator.IN, addedTests);
-            Query q = new Query(TestEntity.KIND).setFilter(propertyFilter).setKeysOnly();
-            List<Entity> newSubscriptions = new ArrayList<>();
-
-            // Create subscription entities
-            for (Entity test : datastore.prepare(q).asIterable()) {
-                UserFavoriteEntity favorite = new UserFavoriteEntity(currentUser, test.getKey());
-                newSubscriptions.add(favorite.toEntity());
-            }
-            datastore.put(newSubscriptions);
-        }
-
-        // Retrieve the removed tests from the request.
-        String removedSubscriptionString = request.getParameter("removedKeys");
-        if (!StringUtils.isBlank(removedSubscriptionString)) {
-            for (String stringKey : removedSubscriptionString.trim().split(",")) {
-                try {
-                    datastore.delete(KeyFactory.stringToKey(stringKey));
-                } catch (IllegalArgumentException e) {
-                    continue;
-                }
-            }
-        }
-        response.sendRedirect(DASHBOARD_MAIN_LINK);
-    }
-}
diff --git a/web/dashboard/src/main/webapp/WEB-INF/datastore-indexes.xml b/web/dashboard/src/main/webapp/WEB-INF/datastore-indexes.xml
index dfade2f..d3f02f3 100644
--- a/web/dashboard/src/main/webapp/WEB-INF/datastore-indexes.xml
+++ b/web/dashboard/src/main/webapp/WEB-INF/datastore-indexes.xml
@@ -14,6 +14,11 @@
 
 <datastore-indexes autoGenerate="true">
 
+  <datastore-index kind="Test" ancestor="false" source="manual">
+    <property name="failCount" direction="asc"/>
+    <property name="passCount" direction="asc"/>
+  </datastore-index>
+
   <datastore-index kind="TestRun" ancestor="true" source="manual">
     <property name="type" direction="asc"/>
     <property name="__key__" direction="desc"/>
diff --git a/web/dashboard/src/main/webapp/WEB-INF/jsp/dashboard_main.jsp b/web/dashboard/src/main/webapp/WEB-INF/jsp/dashboard_main.jsp
index dc6698e..3b72110 100644
--- a/web/dashboard/src/main/webapp/WEB-INF/jsp/dashboard_main.jsp
+++ b/web/dashboard/src/main/webapp/WEB-INF/jsp/dashboard_main.jsp
@@ -19,51 +19,159 @@
 
 <html>
   <link rel='stylesheet' href='/css/dashboard_main.css'>
-  <%@ include file="header.jsp" %>
+  <%@ include file='header.jsp' %>
+  <script type='text/javascript' src='https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.0/jquery-ui.min.js'></script>
   <body>
+    <script>
+        var allTests = ${allTestsJson};
+        var testSet = new Set(allTests);
+        var subscriptionMap = ${subscriptionMapJson};
+
+        var addFavorite = function() {
+            if ($(this).hasClass('disabled')) {
+                return;
+            }
+            var test = $('#input-box').val();
+            if (!testSet.has(test) || test in subscriptionMap) {
+                return;
+            }
+            $('#add-button').addClass('disabled');
+            $.post('/api/favorites/' + test).then(function(data) {
+                if (!data.key) {
+                    return;
+                }
+                subscriptionMap[test] = data.key;
+                var wrapper = $('<div></div>');
+                var a = $('<a></a>')
+                    .attr('href', '/show_table?testName=' + test);
+                var div = $('<div class="col s11 card hoverable option"></div>');
+                div.addClass('valign-wrapper waves-effect');
+                div.appendTo(a);
+                var span = $('<span class="entry valign"></span>').text(test);
+                span.appendTo(div);
+                a.appendTo(wrapper);
+                var clear = $('<a class="col s1 btn-flat center"></a>');
+                clear.addClass('clear-button');
+                clear.append('<i class="material-icons">clear</i>');
+                clear.attr('test', test);
+                clear.appendTo(wrapper);
+                clear.click(removeFavorite);
+                wrapper.prependTo('#options').hide()
+                                          .slideDown(150);
+                $('#input-box').val(null);
+                Materialize.updateTextFields();
+            }).always(function() {
+                $('#add-button').removeClass('disabled');
+            });
+        }
+
+        var removeFavorite = function() {
+            var self = $(this);
+            if (self.hasClass('disabled')) {
+                return;
+            }
+            var test = self.attr('test');
+            if (!(test in subscriptionMap)) {
+                return;
+            }
+            self.addClass('disabled');
+            $.ajax({
+                url: '/api/favorites/' + subscriptionMap[test],
+                type: 'DELETE'
+            }).always(function() {
+                self.removeClass('disabled');
+            }).then(function() {
+                delete subscriptionMap[test];
+                self.parent().slideUp(150, function() {
+                    self.remove();
+                });
+            });
+        }
+
+        $.widget('custom.sizedAutocomplete', $.ui.autocomplete, {
+            _resizeMenu: function() {
+                this.menu.element.outerWidth($('#input-box').width());
+            }
+        });
+
+        $(function() {
+            $('#input-box').sizedAutocomplete({
+                source: allTests,
+                classes: {
+                    'ui-autocomplete': 'card'
+                }
+            });
+
+            $('#input-box').keyup(function(event) {
+                if (event.keyCode == 13) {  // return button
+                    $('#add-button').click();
+                }
+            });
+
+            $('.clear-button').click(removeFavorite);
+            $('#add-button').click(addFavorite);
+        });
+    </script>
     <div class='container'>
-      <div class='row' id='options'>
-        <c:choose>
-          <c:when test="${not empty error}">
-            <div id="error-container" class="row card">
-              <div class="col s12 center-align">
-                <h5>${error}</h5>
+      <c:choose>
+        <c:when test='${not empty error}'>
+          <div id='error-container' class='row card'>
+            <div class='col s12 center-align'>
+              <h5>${error}</h5>
+            </div>
+          </div>
+        </c:when>
+        <c:otherwise>
+          <c:set var='width' value='${showAll ? 12 : 11}' />
+          <c:if test='${not showAll}'>
+            <div class='row'>
+              <div class='input-field col s8'>
+                <input type='text' id='input-box'></input>
+                <label for='input-box'>Search for tests to add to favorites</label>
+              </div>
+              <div id='add-button-wrapper' class='col s1 valign-wrapper'>
+                <a id='add-button' class='btn-floating btn waves-effect waves-light red valign'><i class='material-icons'>add</i></a>
               </div>
             </div>
-          </c:when>
-          <c:otherwise>
+          </c:if>
+          <div class='row'>
             <div class='col s12'>
               <h4 id='section-header'>${headerLabel}</h4>
             </div>
+          </div>
+          <div class='row' id='options'>
             <c:forEach items='${testNames}' var='test'>
-              <a href='/show_table?testName=${test.name}'>
-                <div class='col s12 card hoverable option valign-wrapper waves-effect'>
-                  <span class='entry valign'>${test.name}
-                    <c:if test='${test.failCount > 0}'>
-                      <span class='indicator red center'>
-                        ${test.failCount}
-                      </span>
-                    </c:if>
-                  </span>
-                </div>
-              </a>
+              <div>
+                <a href='/show_table?testName=${test.name}'>
+                  <div class='col s${width} card hoverable option valign-wrapper waves-effect'>
+                    <span class='entry valign'>${test.name}
+                      <c:if test='${test.failCount >= 0 && test.passCount >= 0}'>
+                        <c:set var='color' value='${test.failCount > 0 ? "red" : (test.passCount > 0 ? "green" : "grey")}' />
+                        <span class='indicator center ${color}'>
+                          ${test.passCount} / ${test.passCount + test.failCount}
+                        </span>
+                      </c:if>
+                    </span>
+                  </div>
+                </a>
+                <c:if test='${not showAll}'>
+                  <a class='col s1 btn-flat center clear-button' test='${test.name}'>
+                    <i class='material-icons'>clear</i>
+                  </a>
+                </c:if>
+              </div>
             </c:forEach>
-          </c:otherwise>
-        </c:choose>
-      </div>
-      <div class='row center-align'>
+          </div>
+        </c:otherwise>
+      </c:choose>
+    </div>
+    <c:if test='${empty error}'>
+      <div class='center'>
         <a href='${buttonLink}' id='show-button' class='btn waves-effect red'>${buttonLabel}
           <i id='show-button-arrow' class='material-icons right'>${buttonIcon}</i>
         </a>
       </div>
-    </div>
-    <c:if test='${not showAll}'>
-      <div id='edit-button-wrapper' class='fixed-action-btn'>
-        <a href='/show_preferences' id='edit-button' class='btn-floating btn-large red waves-effect'>
-          <i class='large material-icons'>mode_edit</i>
-        </a>
-      </div>
     </c:if>
-    <%@ include file="footer.jsp" %>
+    <%@ include file='footer.jsp' %>
   </body>
 </html>
diff --git a/web/dashboard/src/main/webapp/WEB-INF/jsp/show_preferences.jsp b/web/dashboard/src/main/webapp/WEB-INF/jsp/show_preferences.jsp
deleted file mode 100644
index ec6ddee..0000000
--- a/web/dashboard/src/main/webapp/WEB-INF/jsp/show_preferences.jsp
+++ /dev/null
@@ -1,153 +0,0 @@
-<%--
-  ~ Copyright (c) 2016 Google Inc. All Rights Reserved.
-  ~
-  ~ Licensed under the Apache License, Version 2.0 (the "License"); you
-  ~ may not use this file except in compliance with the License. You may
-  ~ obtain a copy of the License at
-  ~
-  ~     http://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing, software
-  ~ distributed under the License is distributed on an "AS IS" BASIS,
-  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-  ~ implied. See the License for the specific language governing
-  ~ permissions and limitations under the License.
-  --%>
-<%@ page contentType='text/html;charset=UTF-8' language='java' %>
-<%@ taglib prefix='fn' uri='http://java.sun.com/jsp/jstl/functions' %>
-<%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core'%>
-
-<html>
-  <%@ include file="header.jsp" %>
-  <link rel='stylesheet' href='/css/show_preferences.css'>
-  <script type='text/javascript' src='https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.0/jquery-ui.min.js'></script>
-  <body>
-    <script>
-        var subscriptions = ${subscriptionsJson};
-        var subscriptionMap = ${subscriptionMapJson};
-        var allTests = ${allTestsJson};
-        var testSet = new Set(allTests);
-        var addedSet = new Set();
-        var removedKeySet = new Set();
-
-        var addFunction = function() {
-            var test = $('#input-box').val();
-            if (testSet.has(test) && !subscriptionMap[test] && !addedSet.has(test)) {
-                var icon = $('<i></i>').addClass('material-icons')
-                                       .html('clear');
-                var clear = $('<a></a>').addClass('btn-flat clear-button')
-                                        .append(icon)
-                                        .click(clearFunction);
-                var span = $('<span></span>').addClass('entry valign')
-                                             .html(test);
-                var div = $('<div></div>').addClass('col s12 card option valign-wrapper')
-                                          .append(span).append(clear)
-                                          .prependTo('#options')
-                                          .hide()
-                                          .slideDown(150);
-                if (!subscriptionMap[test]) {
-                    addedSet.add(test);
-                } else {
-                    removedKeySet.delete(subscriptionMap[test].key);
-                }
-                $('#input-box').val('').focusout();
-                if (!addedSet.size && !removedKeySet.size) {
-                    $('#save-button-wrapper').slideUp(50);
-                } else {
-                    $('#save-button-wrapper').slideDown(50);
-                }
-            }
-        }
-
-        var clearFunction = function() {
-            var div = $(this).parent();
-            div.slideUp(150, function() {
-                div.remove();
-            });
-            var test = div.find('span').text();
-            if (subscriptionMap[test]) {
-                removedKeySet.add(subscriptionMap[test].key);
-            } else {
-                addedSet.delete(test);
-            }
-            if (!addedSet.size && !removedKeySet.size) {
-                $('#save-button-wrapper').slideUp(50);
-            } else {
-                $('#save-button-wrapper').slideDown(50);
-            }
-        }
-
-        var submitForm = function() {
-            var added = Array.from(addedSet).join(',');
-            var removed = Array.from(removedKeySet).join(',');
-            $('#prefs-form>input[name="addedTests"]').val(added);
-            $('#prefs-form>input[name="removedKeys"]').val(removed);
-            $('#prefs-form').submit();
-        }
-
-        $.widget('custom.sizedAutocomplete', $.ui.autocomplete, {
-            _resizeMenu: function() {
-                this.menu.element.outerWidth($('#input-box').width());
-            }
-        });
-
-        $(function() {
-            $('#input-box').sizedAutocomplete({
-                source: allTests,
-                classes: {
-                    'ui-autocomplete': 'card'
-                }
-            });
-
-            $('#input-box').keyup(function(event) {
-                if (event.keyCode == 13) {  // return button
-                    $('#add-button').click();
-                }
-            });
-
-            $('.clear-button').click(clearFunction);
-            $('#add-button').click(addFunction);
-            $('#save-button').click(submitForm);
-            $('#save-button-wrapper').hide();
-        });
-    </script>
-    <div class='container'>
-      <div class='row'>
-        <h3 class='col s12 header'>Favorites</h3>
-        <p class='col s12 caption'>Add or remove tests from favorites to customize
-            the dashboard. Tests in your favorites will send you email notifications
-            to let you know when test cases change status.
-        </p>
-      </div>
-      <div class='row'>
-        <div class='input-field col s8'>
-          <input type='text' id='input-box'></input>
-          <label for='input-box'>Search for tests to add to favorites</label>
-        </div>
-        <div id='add-button-wrapper' class='col s1 valign-wrapper'>
-          <a id='add-button' class='btn-floating btn waves-effect waves-light red valign'><i class='material-icons'>add</i></a>
-        </div>
-      </div>
-      <div class='row' id='options'>
-        <c:forEach items='${subscriptions}' var='subscription' varStatus='loop'>
-          <div class='col s12 card option valign-wrapper'>
-            <span class='entry valign' index=${loop.index} key=${subscription.key}>${subscription.testName}</span>
-            <a class='btn-flat clear-button'>
-                <i class='material-icons'>clear</i>
-            </a>
-          </div>
-        </c:forEach>
-      </div>
-    </div>
-    <form id='prefs-form' style='visibility:hidden' action='/show_preferences' method='post'>
-        <input name='addedTests' type='text'>
-        <input name='removedKeys' type='text'>
-    </form>
-    <div id='save-button-wrapper' class='fixed-action-btn'>
-      <a id='save-button' class='btn-floating btn-large red waves-effect'>
-        <i class='large material-icons'>done</i>
-      </a>
-    </div>
-    <%@ include file="footer.jsp" %>
-  </body>
-</html>
diff --git a/web/dashboard/src/main/webapp/WEB-INF/web.xml b/web/dashboard/src/main/webapp/WEB-INF/web.xml
index 4bd1475..9efc059 100644
--- a/web/dashboard/src/main/webapp/WEB-INF/web.xml
+++ b/web/dashboard/src/main/webapp/WEB-INF/web.xml
@@ -46,11 +46,6 @@
 </servlet>
 
 <servlet>
-  <servlet-name>show_preferences</servlet-name>
-  <servlet-class>com.android.vts.servlet.ShowPreferencesServlet</servlet-class>
-</servlet>
-
-<servlet>
   <servlet-name>datastore</servlet-name>
   <servlet-class>com.android.vts.api.DatastoreRestServlet</servlet-class>
 </servlet>
@@ -61,6 +56,11 @@
 </servlet>
 
 <servlet>
+  <servlet-name>favorites</servlet-name>
+  <servlet-class>com.android.vts.api.UserFavoriteRestServlet</servlet-class>
+</servlet>
+
+<servlet>
   <servlet-name>bigtable_legacy</servlet-name>
   <servlet-class>com.android.vts.api.BigtableLegacyJsonServlet</servlet-class>
 </servlet>
@@ -81,11 +81,6 @@
 </servlet-mapping>
 
 <servlet-mapping>
-  <servlet-name>show_preferences</servlet-name>
-  <url-pattern>/show_preferences/*</url-pattern>
-</servlet-mapping>
-
-<servlet-mapping>
   <servlet-name>show_tree</servlet-name>
   <url-pattern>/show_tree/*</url-pattern>
 </servlet-mapping>
@@ -126,6 +121,11 @@
 </servlet-mapping>
 
 <servlet-mapping>
+  <servlet-name>favorites</servlet-name>
+  <url-pattern>/api/favorites/*</url-pattern>
+</servlet-mapping>
+
+<servlet-mapping>
   <servlet-name>vts_alert_job</servlet-name>
   <url-pattern>/cron/vts_alert_job/*</url-pattern>
 </servlet-mapping>
diff --git a/web/dashboard/src/main/webapp/css/dashboard_main.css b/web/dashboard/src/main/webapp/css/dashboard_main.css
index 9dfcf38..f675e00 100644
--- a/web/dashboard/src/main/webapp/css/dashboard_main.css
+++ b/web/dashboard/src/main/webapp/css/dashboard_main.css
@@ -18,20 +18,25 @@
     right: 25px;
 }
 
-th {
-    background: #551A8B;  /* Darken header a bit */
-    font-weight: bold;
-    color: white;
+.input-field {
+    margin-bottom: 50px;
 }
 
-td {
-    background: #912CEE;
-    text-align: center;
+#add-button-wrapper {
+    margin-top: 10px;
+    height: 61px;
 }
 
-.row .col.s12.card.option {
-    padding: 10px 30px;
-    margin: 8px;
+.clear-button {
+    margin-top: 8px;
+    user-select: none;
+    color: grey;
+}
+
+.row .col.card.option {
+    padding: 6px 15px 6px 15px;
+    margin: 5px 0;
+    border-radius: 25px;
 }
 
 #error-container {
@@ -40,7 +45,7 @@
 }
 
 .entry {
-    font-size: 21px;
+    font-size: 20px;
     font-weight: 300;
     position: relative;
 }
@@ -54,11 +59,7 @@
     right: 0;
     min-width: 40px;
     border-radius: 10px;
-    margin-top: 6px;
-}
-
-#options {
-    min-height: 80%;
+    margin-top: 5px;
 }
 
 #show-button {
@@ -81,4 +82,26 @@
     margin-bottom: 30px;
     display: block;
     content: " ";
-}
\ No newline at end of file
+}
+
+.ui-menu {
+    z-index: 100;
+}
+
+.ui-menu-item {
+    font-size: 16px;
+    padding: 4px 10px;
+    transition: background-color .25s;
+}
+
+.ui-menu-item:hover {
+    background-color: #e0f2f1;
+}
+
+.ui-menu-item:active {
+    background-color: #b2dfdb;
+}
+
+.ui-helper-hidden-accessible {
+    display: none;
+}
diff --git a/web/dashboard/src/main/webapp/css/show_preferences.css b/web/dashboard/src/main/webapp/css/show_preferences.css
deleted file mode 100644
index 9731037..0000000
--- a/web/dashboard/src/main/webapp/css/show_preferences.css
+++ /dev/null
@@ -1,75 +0,0 @@
-/* Copyright (C) 2016 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
-      http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
-*/
-
-.card.option {
-    padding: 10px 30px;
-    margin: 8px;
-    color: #039be5;
-}
-
-#add-button-wrapper {
-    margin-top: 10px;
-    height: 61px;
-}
-
-#save-button-wrapper {
-    bottom: 25px;
-    right: 25px;
-}
-
-.clear-button {
-    position: absolute;
-    right: 0;
-}
-
-.header {
-    cursor: default;
-    color: #ee6e73;
-    margin-bottom: 0;
-}
-
-.caption {
-    cursor: default;
-    font-size: 20px;
-    font-weight: 300;
-    margin-bottom: 30px;
-}
-
-.entry {
-    font-size: 21px;
-    font-weight: 300;
-}
-
-.ui-menu {
-    z-index: 100;
-}
-
-.ui-menu-item {
-    font-size: 16px;
-    padding: 4px 10px;
-    transition: background-color .25s;
-}
-
-.ui-menu-item:hover {
-    background-color: #e0f2f1;
-}
-
-.ui-menu-item:active {
-    background-color: #b2dfdb;
-}
-
-.ui-helper-hidden-accessible {
-    display: none;
-}
\ No newline at end of file