ChangeScreen2: Support adding reviewers

Bug: issue 2066
Change-Id: I3654d019ce09c1b2c234c0b73f5d6c0e6c8ae0b9
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
index 09aef22..c6940de 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
@@ -120,7 +120,7 @@
   @UiField AnchorElement permalink;
 
   @UiField Element reviewersText;
-  @UiField Element ccText;
+  @UiField Reviewers reviewers;
   @UiField Element changeIdText;
   @UiField Element ownerText;
   @UiField Element statusText;
@@ -208,6 +208,7 @@
     Resources.I.style().ensureInjected();
     star.setVisible(Gerrit.isSignedIn());
     labels.init(style, statusText);
+    reviewers.init(style);
 
     keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
     keysNavigation.add(new KeyCommand(0, 'u', Util.C.upToChangeList()) {
@@ -241,6 +242,12 @@
           star.setValue(!star.getValue(), true);
         }
       });
+      keysAction.add(new KeyCommand(0, 'c', Util.C.keyAddReviewers()) {
+        @Override
+        public void onKeyPress(KeyPressEvent event) {
+          reviewers.onOpenForm();
+        }
+      });
     }
   }
 
@@ -654,8 +661,9 @@
     }
     r.remove(info.owner()._account_id());
     cc.remove(info.owner()._account_id());
-    reviewersText.setInnerSafeHtml(labels.formatUserList(r.values()));
-    ccText.setInnerSafeHtml(labels.formatUserList(cc.values()));
+    reviewersText.setInnerSafeHtml(Labels.formatUserList(style, r.values()));
+    reviewers.set(info.legacy_id());
+    reviewers.setReviewers(Labels.formatUserList(style, cc.values()));
   }
 
   private void renderOwner(ChangeInfo info) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml
index 6cf7b7b..2c47c4d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.ui.xml
@@ -281,7 +281,9 @@
             </tr>
             <tr>
               <th><ui:msg>CC</ui:msg></th>
-              <td ui:field='ccText'/>
+              <td>
+                <c:Reviewers ui:field='reviewers'/>
+              </td>
             </tr>
             <tr>
               <th><ui:msg>Project</ui:msg></th>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
index f3ce7fc..6316c64 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
@@ -123,7 +123,7 @@
         html.setStyleName(style.label_reject());
       }
       html.append(val).append(" ");
-      html.append(formatUserList(m.get(v)));
+      html.append(formatUserList(style, m.get(v)));
       html.closeSpan();
     }
     return html.toBlockWidget();
@@ -167,7 +167,8 @@
     }
   }
 
-  SafeHtml formatUserList(Collection<? extends AccountInfo> in) {
+  static SafeHtml formatUserList(ChangeScreen2.Style style,
+      Collection<? extends AccountInfo> in) {
     List<AccountInfo> users = new ArrayList<AccountInfo>(in);
     Collections.sort(users, new Comparator<AccountInfo>() {
       @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestReviewerSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestReviewerSuggestOracle.java
new file mode 100644
index 0000000..7575869
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestReviewerSuggestOracle.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2013 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.
+
+package com.google.gerrit.client.change;
+
+import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.account.AccountInfo;
+import com.google.gerrit.client.admin.Util;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.groups.GroupBaseInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.user.client.ui.SuggestOracle;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** REST API based suggestion Oracle for reviewers. */
+public class RestReviewerSuggestOracle extends SuggestAfterTypingNCharsOracle {
+
+  private Change.Id changeId;
+
+  @Override
+  protected void _onRequestSuggestions(final Request req, final Callback callback) {
+    ChangeApi.suggestReviewers(changeId.get(), req.getQuery(),
+        req.getLimit()).get(new GerritCallback<JsArray<SuggestReviewerInfo>>() {
+          @Override
+          public void onSuccess(JsArray<SuggestReviewerInfo> result) {
+            final List<RestReviewerSuggestion> r =
+                new ArrayList<RestReviewerSuggestion>(result.length());
+            for (final SuggestReviewerInfo reviewer : Natives.asList(result)) {
+              r.add(new RestReviewerSuggestion(reviewer));
+            }
+            callback.onSuggestionsReady(req, new Response(r));
+          }
+        });
+  }
+
+  public void setChange(Change.Id changeId) {
+    this.changeId = changeId;
+  }
+
+  private static class RestReviewerSuggestion implements SuggestOracle.Suggestion {
+    private final SuggestReviewerInfo reviewer;
+
+    RestReviewerSuggestion(final SuggestReviewerInfo reviewer) {
+      this.reviewer = reviewer;
+    }
+
+    public String getDisplayString() {
+      if (reviewer.account() != null) {
+        return FormatUtil.nameEmail(reviewer.account());
+      }
+      return reviewer.group().name()
+          + " ("
+          + Util.C.suggestedGroupLabel()
+          + ")";
+    }
+
+    public String getReplacementString() {
+      if (reviewer.account() != null) {
+        return FormatUtil.nameEmail(reviewer.account());
+      }
+      return reviewer.group().name();
+    }
+  }
+
+  public static class SuggestReviewerInfo extends JavaScriptObject {
+    public final native AccountInfo account() /*-{ return this.account; }-*/;
+    public final native GroupBaseInfo group() /*-{ return this.group; }-*/;
+    protected SuggestReviewerInfo() {
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
new file mode 100644
index 0000000..b4e1f55
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2013 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.
+
+package com.google.gerrit.client.change;
+
+import com.google.gerrit.client.ConfirmationCallback;
+import com.google.gerrit.client.ConfirmationDialog;
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.account.AccountInfo;
+import com.google.gerrit.client.changes.ApprovalTable.PostInput;
+import com.google.gerrit.client.changes.ApprovalTable.PostResult;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.ChangeInfo;
+import com.google.gerrit.client.changes.ChangeInfo.ApprovalInfo;
+import com.google.gerrit.client.changes.ChangeInfo.LabelInfo;
+import com.google.gerrit.client.changes.Util;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.ui.HintTextBox;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.KeyCodes;
+import com.google.gwt.event.dom.client.KeyDownEvent;
+import com.google.gwt.event.dom.client.KeyDownHandler;
+import com.google.gwt.event.logical.shared.SelectionEvent;
+import com.google.gwt.event.logical.shared.SelectionHandler;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.rpc.StatusCodeException;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.SuggestBox;
+import com.google.gwt.user.client.ui.SuggestBox.DefaultSuggestionDisplay;
+import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
+import com.google.gwt.user.client.ui.UIObject;
+import com.google.gwtexpui.safehtml.client.SafeHtml;
+import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** Add reviewers. */
+class Reviewers extends Composite {
+  interface Binder extends UiBinder<HTMLPanel, Reviewers> {}
+  private static final Binder uiBinder = GWT.create(Binder.class);
+
+  @UiField Button openForm;
+  @UiField Element reviewers;
+  @UiField Element form;
+  @UiField Element error;
+  @UiField(provided = true)
+  SuggestBox suggestBox;
+
+  private RestReviewerSuggestOracle reviewerSuggestOracle;
+  private HintTextBox nameTxtBox;
+  private Change.Id changeId;
+  private ChangeScreen2.Style style;
+  private boolean submitOnSelection;
+
+  Reviewers() {
+    reviewerSuggestOracle = new RestReviewerSuggestOracle();
+    nameTxtBox = new HintTextBox();
+    suggestBox = new SuggestBox(reviewerSuggestOracle, nameTxtBox);
+    initWidget(uiBinder.createAndBindUi(this));
+
+    nameTxtBox.setVisibleLength(55);
+    nameTxtBox.setHintText(Util.C.approvalTableAddReviewerHint());
+    nameTxtBox.addKeyDownHandler(new KeyDownHandler() {
+      @Override
+      public void onKeyDown(KeyDownEvent e) {
+        submitOnSelection = false;
+
+        if (e.getNativeEvent().getKeyCode() == KeyCodes.KEY_ESCAPE) {
+          onCancel(null);
+        } else if (e.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+          if (((DefaultSuggestionDisplay) suggestBox.getSuggestionDisplay())
+              .isSuggestionListShowing()) {
+            submitOnSelection = true;
+          } else {
+            onAdd(null);
+          }
+        }
+      }
+    });
+    suggestBox.addSelectionHandler(new SelectionHandler<Suggestion>() {
+      @Override
+      public void onSelection(SelectionEvent<Suggestion> event) {
+        nameTxtBox.setFocus(true);
+        if (submitOnSelection) {
+          onAdd(null);
+        }
+      }
+    });
+  }
+
+  void set(Change.Id changeId) {
+    this.changeId = changeId;
+    reviewerSuggestOracle.setChange(changeId);
+  }
+
+  void init(ChangeScreen2.Style style) {
+    this.style = style;
+    openForm.setVisible(Gerrit.isSignedIn());
+  }
+
+  void setReviewers(SafeHtml formatUserList) {
+    reviewers.setInnerSafeHtml(formatUserList);
+  }
+
+  @UiHandler("openForm")
+  void onOpenForm(ClickEvent e) {
+    onOpenForm();
+  }
+
+  void onOpenForm() {
+    UIObject.setVisible(form, true);
+    UIObject.setVisible(error, false);
+    openForm.setVisible(false);
+    suggestBox.setFocus(true);
+  }
+
+  @UiHandler("add")
+  void onAdd(ClickEvent e) {
+    String reviewer = suggestBox.getText();
+    if (!reviewer.isEmpty()) {
+      addReviewer(reviewer, false);
+    }
+  }
+
+  @UiHandler("cancel")
+  void onCancel(ClickEvent e) {
+    openForm.setVisible(true);
+    UIObject.setVisible(form, false);
+    suggestBox.setFocus(false);
+  }
+
+  private void addReviewer(final String reviewer, boolean confirmed) {
+    ChangeApi.reviewers(changeId.get()).post(
+        PostInput.create(reviewer, confirmed),
+        new GerritCallback<PostResult>() {
+          public void onSuccess(PostResult result) {
+            nameTxtBox.setEnabled(true);
+
+            if (result.confirm()) {
+              askForConfirmation(result.error());
+            } else if (result.error() != null) {
+              UIObject.setVisible(error, true);
+              error.setInnerText(result.error());
+            } else {
+              UIObject.setVisible(error, false);
+              error.setInnerText("");
+              nameTxtBox.setText("");
+
+              if (result.reviewers() != null
+                  && result.reviewers().length() > 0) {
+                updateReviewerList();
+              }
+            }
+          }
+
+          private void askForConfirmation(String text) {
+            new ConfirmationDialog(
+                Util.C.approvalTableAddManyReviewersConfirmationDialogTitle(),
+                new SafeHtmlBuilder().append(text),
+                new ConfirmationCallback() {
+                  @Override
+                  public void onOk() {
+                    addReviewer(reviewer, true);
+                  }
+                }).center();
+          }
+
+          @Override
+          public void onFailure(Throwable err) {
+            UIObject.setVisible(error, true);
+            error.setInnerText(err instanceof StatusCodeException
+                ? ((StatusCodeException) err).getEncodedResponse()
+                : err.getMessage());
+            nameTxtBox.setEnabled(true);
+          }
+        });
+  }
+
+  private void updateReviewerList() {
+    ChangeApi.detail(changeId.get(),
+        new GerritCallback<ChangeInfo>() {
+          @Override
+          public void onSuccess(ChangeInfo result) {
+            display(result);
+          }
+        });
+  }
+
+  private void display(ChangeInfo info) {
+    Map<Integer, AccountInfo> r = new HashMap<Integer, AccountInfo>();
+    Map<Integer, AccountInfo> cc = new HashMap<Integer, AccountInfo>();
+    for (LabelInfo label : Natives.asList(info.all_labels().values())) {
+      if (label.all() != null) {
+        for (ApprovalInfo ai : Natives.asList(label.all())) {
+          (ai.value() != 0 ? r : cc).put(ai._account_id(), ai);
+        }
+      }
+    }
+    for (Integer i : r.keySet()) {
+      cc.remove(i);
+    }
+    cc.remove(info.owner()._account_id());
+    setReviewers(Labels.formatUserList(style, cc.values()));
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml
new file mode 100644
index 0000000..568e950
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 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.
+-->
+<ui:UiBinder
+    xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
+  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
+  <ui:style>
+    .openAdd {
+      cursor: pointer;
+      float: right;
+      padding: 0;
+      margin: 0;
+      border: 0;
+      background-color: transparent;
+    }
+
+    .suggestBox {
+      margin-bottom: 2px;
+    }
+
+    .error {
+      color: #D33D3D;
+      font-weight: bold;
+    }
+
+    .cancel {
+      float: right;
+    }
+  </ui:style>
+  <g:HTMLPanel>
+    <div>
+      <span ui:field='reviewers'/>
+      <g:Button ui:field='openForm'
+         title='Add reviewers to this change'
+         styleName='{style.openAdd}'
+         visible='false'>
+       <ui:attribute name='title'/>
+       <div>[+]</div>
+      </g:Button>
+    </div>
+    <div ui:field='form' style='display: none' aria-hidden='true'>
+      <g:SuggestBox ui:field='suggestBox' styleName='{style.suggestBox}'/>
+      <div ui:field='error'
+           class='{style.error}'
+           style='display: none' aria-hidden='true'/>
+      <div>
+        <g:Button ui:field='add' styleName='{res.style.button}'>
+          <div>Add</div>
+        </g:Button>
+        <g:Button ui:field='cancel'
+            styleName='{res.style.button}'
+            addStyleNames='{style.cancel}'>
+          <div>Cancel</div>
+        </g:Button>
+      </div>
+    </div>
+   </g:HTMLPanel>
+  </ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ApprovalTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ApprovalTable.java
index 93c9505..350f97f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ApprovalTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ApprovalTable.java
@@ -254,8 +254,8 @@
     }
   }
 
-  private static class PostInput extends JavaScriptObject {
-    static PostInput create(String reviewer, boolean confirmed) {
+  public static class PostInput extends JavaScriptObject {
+    public static PostInput create(String reviewer, boolean confirmed) {
       PostInput input = createObject().cast();
       input.init(reviewer, confirmed);
       return input;
@@ -272,7 +272,7 @@
     }
   }
 
-  private static class ReviewerInfo extends AccountInfo {
+  public static class ReviewerInfo extends AccountInfo {
     final Set<String> approvals() {
       return Natives.keys(_approvals());
     }
@@ -283,10 +283,10 @@
     }
   }
 
-  private static class PostResult extends JavaScriptObject {
-    final native JsArray<ReviewerInfo> reviewers() /*-{ return this.reviewers; }-*/;
-    final native boolean confirm() /*-{ return this.confirm || false; }-*/;
-    final native String error() /*-{ return this.error; }-*/;
+  public static class PostResult extends JavaScriptObject {
+    public final native JsArray<ReviewerInfo> reviewers() /*-{ return this.reviewers; }-*/;
+    public final native boolean confirm() /*-{ return this.confirm || false; }-*/;
+    public final native String error() /*-{ return this.error; }-*/;
 
     protected PostResult() {
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index c292154..9d76104 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -86,6 +86,12 @@
     return change(id).view("reviewers");
   }
 
+  public static RestApi suggestReviewers(int id, String q, int n) {
+    return change(id).view("suggest_reviewers")
+        .addParameter("q", q)
+        .addParameter("n", n);
+  }
+
   public static RestApi reviewer(int id, int reviewer) {
     return change(id).view("reviewers").id(reviewer);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index 197b466..81ab944 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -61,6 +61,7 @@
   String keyPublishComments();
   String keyEditTopic();
   String keyEditMessage();
+  String keyAddReviewers();
 
   String patchTableColumnName();
   String patchTableColumnComments();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index f51cff0..86623dc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -41,6 +41,7 @@
 keyPublishComments = Review and publish comments
 keyEditTopic = Edit change topic
 keyEditMessage = Edit commit message
+keyAddReviewers = Add reviewers
 
 
 patchTableColumnName = File Path