Implement an isDirty() method for Editor framework drivers.
Issue 5881.
Patch by: bobv
Review by: rjrjr

Review at http://gwt-code-reviews.appspot.com/1306801


git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@9575 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/user/src/com/google/gwt/editor/client/EditorDelegate.java b/user/src/com/google/gwt/editor/client/EditorDelegate.java
index 87abeb3..8009ebb 100644
--- a/user/src/com/google/gwt/editor/client/EditorDelegate.java
+++ b/user/src/com/google/gwt/editor/client/EditorDelegate.java
@@ -29,7 +29,7 @@
 public interface EditorDelegate<T> {
   /**
    * Returns the Editor's path, relative to the root object.
-   *
+   * 
    * @return the path as a String
    */
   String getPath();
@@ -50,6 +50,22 @@
   void recordError(String message, Object value, Object userData);
 
   /**
+   * Toggle the dirty-state flag for the Editor.
+   * <p>
+   * The dirty state of an Editor will be automatically cleared any time the
+   * Driver's {@code edit()} or {@code flush()} methods are called.
+   * <p>
+   * The dirty state will be automatically calculated for
+   * {@link LeafValueEditor} instances based on an {@link Object#equals(Object)}
+   * comparison of {@link LeafValueEditor#getValue()} and the value last passed
+   * to {@link LeafValueEditor#setValue(Object)}, however a clean state can be
+   * overridden by calling {@code setDirty(true)}.
+   * 
+   * @param dirty the dirty state of the Editor
+   */
+  void setDirty(boolean dirty);
+
+  /**
    * Register for notifications if object being edited is updated. Not all
    * backends support subscriptions and will return <code>null</code>.
    * <p>
diff --git a/user/src/com/google/gwt/editor/client/SimpleBeanEditorDriver.java b/user/src/com/google/gwt/editor/client/SimpleBeanEditorDriver.java
index 5b5168a..017dcb5 100644
--- a/user/src/com/google/gwt/editor/client/SimpleBeanEditorDriver.java
+++ b/user/src/com/google/gwt/editor/client/SimpleBeanEditorDriver.java
@@ -84,6 +84,14 @@
   void initialize(E editor);
 
   /**
+   * Returns {@code true} if any of the Editors in the hierarchy have been
+   * modified relative to the last value passed into {@link #edit(Object)}.
+   * 
+   * @see EditorDelegate#setDirty(boolean)
+   */
+  boolean isDirty();
+
+  /**
    * Show {@link ConstraintViolation ConstraintViolations} generated through a
    * {@link javax.validation.Validator Validator}. The violations will be
    * converted into {@link EditorError} objects whose
diff --git a/user/src/com/google/gwt/editor/client/impl/AbstractEditorDelegate.java b/user/src/com/google/gwt/editor/client/impl/AbstractEditorDelegate.java
index ee85f62..ca3c96b 100644
--- a/user/src/com/google/gwt/editor/client/impl/AbstractEditorDelegate.java
+++ b/user/src/com/google/gwt/editor/client/impl/AbstractEditorDelegate.java
@@ -104,9 +104,15 @@
   }
 
   protected CompositeEditor<T, Object, Editor<Object>> composedEditor;
+  protected boolean dirty;
   protected Chain<Object, Editor<Object>> editorChain;
   protected List<EditorError> errors;
   protected HasEditorErrors<T> hasEditorErrors;
+  protected T lastLeafValue;
+  /**
+   * Records values last set into any sub-editors that are leaves.
+   */
+  protected Map<String, Object> lastLeafValues;
   protected LeafValueEditor<T> leafValueEditor;
   protected String path;
   /**
@@ -119,10 +125,6 @@
    */
   protected ValueAwareEditor<T> valueAwareEditor;
 
-  public AbstractEditorDelegate() {
-    super();
-  }
-
   /**
    * Flushes both data and errors.
    */
@@ -162,6 +164,18 @@
     return path;
   }
 
+  public boolean isDirty() {
+    if (dirty) {
+      return true;
+    }
+    if (leafValueEditor != null) {
+      if (!equals(lastLeafValue, leafValueEditor.getValue())) {
+        return true;
+      }
+    }
+    return isDirtyCheckLeaves();
+  }
+
   public void recordError(String message, Object value, Object userData) {
     EditorError error = new SimpleError(this, message, value, userData);
     errors.add(error);
@@ -175,8 +189,10 @@
   }
 
   public void refresh(T object) {
+    dirty = false;
     setObject(ensureMutable(object));
     if (leafValueEditor != null) {
+      lastLeafValue = object;
       leafValueEditor.setValue(object);
     } else if (valueAwareEditor != null) {
       valueAwareEditor.setValue(object);
@@ -184,6 +200,10 @@
     refreshEditors();
   }
 
+  public void setDirty(boolean dirty) {
+    this.dirty = dirty;
+  }
+
   public abstract HandlerRegistration subscribe();
 
   protected String appendPath(String path) {
@@ -206,6 +226,20 @@
     return object;
   }
 
+  /**
+   * Utility method used by generated subtypes that handles null vs. non-null
+   * comparisons.
+   */
+  protected boolean equals(Object a, Object b) {
+    if (a == b) {
+      return true;
+    }
+    if (a != null && a.equals(b)) {
+      return true;
+    }
+    return false;
+  }
+
   protected abstract void flushSubEditorErrors(
       List<EditorError> errorAccumulator);
 
@@ -217,6 +251,11 @@
     return simpleEditors.get(declaredPath);
   }
 
+  /**
+   * Returns {@code true} if the editor contains leaf editors without delegates.
+   */
+  protected abstract boolean hasSubEditorsWithoutDelegates();
+
   protected void initialize(String pathSoFar, T object, E editor,
       DelegateMap map) {
     this.path = pathSoFar;
@@ -224,6 +263,9 @@
     setObject(ensureMutable(object));
     errors = new ArrayList<EditorError>();
     simpleEditors = new HashMap<String, Editor<?>>();
+    if (hasSubEditorsWithoutDelegates()) {
+      lastLeafValues = new HashMap<String, Object>();
+    }
 
     // Set up pre-casted fields to access the editor
     if (editor instanceof HasEditorErrors<?>) {
@@ -252,6 +294,7 @@
      * happened, only set the value and don't descend into any sub-Editors.
      */
     if (leafValueEditor != null) {
+      lastLeafValue = object;
       leafValueEditor.setValue(object);
       return;
     }
@@ -273,6 +316,13 @@
       S subEditor, DelegateMap map);
 
   /**
+   * Returns {@code true} if any leaf sub-editors are dirty.
+   * 
+   * @see #lastLeafValues
+   */
+  protected abstract boolean isDirtyCheckLeaves();
+
+  /**
    * Refresh all of the sub-editors.
    */
   protected abstract void refreshEditors();
diff --git a/user/src/com/google/gwt/editor/client/impl/AbstractSimpleBeanEditorDriver.java b/user/src/com/google/gwt/editor/client/impl/AbstractSimpleBeanEditorDriver.java
index 55330b8..802f86b 100644
--- a/user/src/com/google/gwt/editor/client/impl/AbstractSimpleBeanEditorDriver.java
+++ b/user/src/com/google/gwt/editor/client/impl/AbstractSimpleBeanEditorDriver.java
@@ -67,6 +67,15 @@
     this.editor = editor;
   }
 
+  public boolean isDirty() {
+    for (AbstractEditorDelegate<?, ?> d : delegateMap) {
+      if (d.isDirty()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   public boolean setConstraintViolations(
       final Iterable<ConstraintViolation<?>> violations) {
     checkDelegate();
diff --git a/user/src/com/google/gwt/editor/client/impl/DelegateMap.java b/user/src/com/google/gwt/editor/client/impl/DelegateMap.java
index f20ee51..fa336ab 100644
--- a/user/src/com/google/gwt/editor/client/impl/DelegateMap.java
+++ b/user/src/com/google/gwt/editor/client/impl/DelegateMap.java
@@ -17,13 +17,14 @@
 
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 
 /**
  * Allows fast traversal of an Editor hierarchy.
  */
-public class DelegateMap {
+public class DelegateMap implements Iterable<AbstractEditorDelegate<?, ?>> {
   /**
    * 
    */
@@ -31,6 +32,47 @@
     Object key(Object object);
   }
 
+  private static class MapIterator implements
+      Iterator<AbstractEditorDelegate<?, ?>> {
+    private AbstractEditorDelegate<?, ?> next;
+    private Iterator<AbstractEditorDelegate<?, ?>> list;
+    private Iterator<List<AbstractEditorDelegate<?, ?>>> values;
+
+    public MapIterator(DelegateMap map) {
+      values = map.map.values().iterator();
+      next();
+    }
+
+    public boolean hasNext() {
+      return next != null;
+    }
+
+    public AbstractEditorDelegate<?, ?> next() {
+      AbstractEditorDelegate<?, ?> toReturn = next;
+
+      if (list != null && list.hasNext()) {
+        // Simple case, just advance the pointer
+        next = list.next();
+      } else {
+        // Uninitialized, or current list exhausted
+        next = null;
+        while (values.hasNext()) {
+          // Find the next non-empty iterator
+          list = values.next().iterator();
+          if (list.hasNext()) {
+            next = list.next();
+            break;
+          }
+        }
+      }
+      return toReturn;
+    }
+
+    public void remove() {
+      throw new UnsupportedOperationException();
+    }
+  }
+
   public static final KeyMethod IDENTITY = new KeyMethod() {
     public Object key(Object object) {
       return object;
@@ -64,6 +106,10 @@
     return map.get(key);
   }
 
+  public Iterator<AbstractEditorDelegate<?, ?>> iterator() {
+    return new MapIterator(this);
+  }
+
   public <T> void put(T object, AbstractEditorDelegate<T, ?> delegate) {
     {
       List<AbstractEditorDelegate<?, ?>> list = paths.get(delegate.getPath());
diff --git a/user/src/com/google/gwt/editor/client/testing/MockEditorDelegate.java b/user/src/com/google/gwt/editor/client/testing/MockEditorDelegate.java
index 3706468..ba90c7e 100644
--- a/user/src/com/google/gwt/editor/client/testing/MockEditorDelegate.java
+++ b/user/src/com/google/gwt/editor/client/testing/MockEditorDelegate.java
@@ -29,6 +29,7 @@
     }
   };
 
+  private boolean dirty;
   private String path = "";
 
   /**
@@ -39,12 +40,28 @@
   }
 
   /**
+   * Returns {@code false} or the last value passed to
+   * {@link #setDirty(boolean)}.
+   */
+  public boolean isDirty() {
+    return dirty;
+  }
+
+  /**
    * No-op.
    */
   public void recordError(String message, Object value, Object userData) {
   }
 
   /**
+   * Records the value of {@code dirty} which can be retrieved from
+   * {@link #isDirty()}.
+   */
+  public void setDirty(boolean dirty) {
+    this.dirty = dirty;
+  }
+
+  /**
    * Controls the return value of {@link #getPath()}.
    */
   public void setPath(String path) {
diff --git a/user/src/com/google/gwt/editor/client/testing/MockSimpleBeanEditorDriver.java b/user/src/com/google/gwt/editor/client/testing/MockSimpleBeanEditorDriver.java
index 01bb3f1..c098255 100644
--- a/user/src/com/google/gwt/editor/client/testing/MockSimpleBeanEditorDriver.java
+++ b/user/src/com/google/gwt/editor/client/testing/MockSimpleBeanEditorDriver.java
@@ -88,6 +88,13 @@
   }
 
   /**
+   * Returns {@code false}.
+   */
+  public boolean isDirty() {
+    return false;
+  }
+
+  /**
    * A no-op method that always returns false.
    */
   public boolean setConstraintViolations(
diff --git a/user/src/com/google/gwt/editor/rebind/AbstractEditorDriverGenerator.java b/user/src/com/google/gwt/editor/rebind/AbstractEditorDriverGenerator.java
index 56579a6..e7ff8b7 100644
--- a/user/src/com/google/gwt/editor/rebind/AbstractEditorDriverGenerator.java
+++ b/user/src/com/google/gwt/editor/rebind/AbstractEditorDriverGenerator.java
@@ -173,13 +173,22 @@
           sw.println("delegateMap.put(%1$s.getObject(), %1$s);",
               delegateFields.get(d));
         } else if (d.isLeafValueEditor()) {
-          // if (can().access().without().npe()) { editor.subEditor.setValue() }
-          sw.println("if (%4$s) editor.%1$s.setValue(getObject()%2$s%3$s);",
-              d.getSimpleExpression(), d.getBeanOwnerExpression(),
-              d.getGetterExpression(), d.getBeanOwnerGuard("getObject()"));
-          // simpleEditor.put("some.path", editor.simpleEditor());
+          // if (can().access().without().npe()) {
+          sw.println("if (%s) {", d.getBeanOwnerGuard("getObject()"));
+          sw.indent();
+          // Bar value = getObject()....;
+          sw.println("%s value = getObject()%s%s;",
+              d.getEditedType().getQualifiedSourceName(),
+              d.getBeanOwnerExpression(), d.getGetterExpression());
+          // editor.subEditor.setValue(value);
+          sw.println("editor.%s.setValue(value);", d.getSimpleExpression());
+          // simpleEditors.put("foo.bar", editor.subEditor);
           sw.println("simpleEditors.put(\"%s\", editor.%s);",
               d.getDeclaredPath(), d.getSimpleExpression());
+          // lastLeafValues.put("foo.bar", value);
+          sw.println("lastLeafValues.put(\"%s\", value);", d.getDeclaredPath());
+          sw.outdent();
+          sw.println("}");
         }
         sw.outdent();
         sw.println("}");
@@ -247,6 +256,39 @@
       sw.outdent();
       sw.println("}");
 
+      sw.println("protected boolean hasSubEditorsWithoutDelegates() {");
+      boolean hasSubEditorsWithoutDelegates = false;
+      for (EditorData d : data) {
+        if (!d.isDelegateRequired()) {
+          hasSubEditorsWithoutDelegates = true;
+          break;
+        }
+      }
+      sw.indentln("return %s;", hasSubEditorsWithoutDelegates ? "true"
+          : "false");
+      sw.println("}");
+
+      // isDirty() traversal method for sub-editors without delegates
+      sw.println("protected boolean isDirtyCheckLeaves() {");
+      sw.indent();
+      if (hasSubEditorsWithoutDelegates) {
+        for (EditorData d : data) {
+          if (!d.isDelegateRequired()) {
+            // if (editor.subEditor != null &&
+            sw.println("if (editor.%s != null &&", d.getSimpleExpression());
+            // !equals(editor.sub.getValue(), lastLeafValues.get("foo.bar"))) {
+            sw.indentln(
+                "!equals(editor.%s.getValue(), lastLeafValues.get(\"%s\"))) {",
+                d.getSimpleExpression(), d.getDeclaredPath());
+            sw.indentln("return true;");
+            sw.println("}");
+          }
+        }
+      }
+      sw.println("return false;");
+      sw.outdent();
+      sw.println("}");
+
       // Reset the data being displayed
       sw.println("protected void refreshEditors() {",
           DelegateMap.class.getCanonicalName());
@@ -269,13 +311,24 @@
           // if (editor.subEditor != null) {
           sw.println("if (editor.%s != null) {", d.getSimpleExpression());
           sw.indent();
-          // if (can().access().without().npe()) { editor.subEditor.setValue() }
-          sw.println("if (%4$s) editor.%1$s.setValue(getObject()%2$s%3$s);",
-              d.getSimpleExpression(), d.getBeanOwnerExpression(),
-              d.getGetterExpression(), d.getBeanOwnerGuard("getObject()"));
-          // else { editor.subEditor.setValue(null); }
-          sw.println("else { editor.%s.setValue(null); }",
-              d.getSimpleExpression());
+          // if (can().access().without().npe()) {
+          sw.println("if (%s) {", d.getBeanOwnerGuard("getObject()"));
+          sw.indent();
+          // Bar value = getObject()....;
+          sw.println("%s value = getObject()%s%s;",
+              d.getEditedType().getQualifiedSourceName(),
+              d.getBeanOwnerExpression(), d.getGetterExpression());
+          // editor.subEditor.setValue(value);
+          sw.println("editor.%s.setValue(value);", d.getSimpleExpression());
+          // lastLeafValues.put("foo.bar", value);
+          sw.println("lastLeafValues.put(\"%s\", value);", d.getDeclaredPath());
+          sw.outdent();
+          sw.println("} else {");
+          sw.indent();
+          sw.println("editor.%s.setValue(null);", d.getSimpleExpression());
+          sw.println("lastLeafValues.put(\"%s\", null);", d.getDeclaredPath());
+          sw.outdent();
+          sw.println("}");
           sw.outdent();
           sw.println("}");
         }
diff --git a/user/src/com/google/gwt/requestfactory/client/RequestFactoryEditorDriver.java b/user/src/com/google/gwt/requestfactory/client/RequestFactoryEditorDriver.java
index cf20de9..9d0ee99 100644
--- a/user/src/com/google/gwt/requestfactory/client/RequestFactoryEditorDriver.java
+++ b/user/src/com/google/gwt/requestfactory/client/RequestFactoryEditorDriver.java
@@ -139,6 +139,14 @@
   void initialize(E editor);
 
   /**
+   * Returns {@code true} if any of the Editors in the hierarchy have been
+   * modified relative to the last value passed into {@link #edit(Object)}.
+   * 
+   * @see com.google.gwt.editor.client.EditorDelegate#setDirty(boolean)
+   */
+  boolean isDirty();
+
+  /**
    * Show {@link ConstraintViolation ConstraintViolations} generated through a
    * JSR 303 Validator. The violations will be converted into
    * {@link EditorError} objects whose {@link EditorError#getUserData()
diff --git a/user/src/com/google/gwt/requestfactory/client/impl/AbstractRequestFactoryEditorDriver.java b/user/src/com/google/gwt/requestfactory/client/impl/AbstractRequestFactoryEditorDriver.java
index 416c984..672c2c8 100644
--- a/user/src/com/google/gwt/requestfactory/client/impl/AbstractRequestFactoryEditorDriver.java
+++ b/user/src/com/google/gwt/requestfactory/client/impl/AbstractRequestFactoryEditorDriver.java
@@ -17,6 +17,7 @@
 
 import com.google.gwt.editor.client.Editor;
 import com.google.gwt.editor.client.EditorError;
+import com.google.gwt.editor.client.impl.AbstractEditorDelegate;
 import com.google.gwt.editor.client.impl.DelegateMap;
 import com.google.gwt.editor.client.impl.SimpleViolation;
 import com.google.gwt.event.shared.EventBus;
@@ -193,6 +194,15 @@
     initialize(requestFactory.getEventBus(), requestFactory, editor);
   }
 
+  public boolean isDirty() {
+    for (AbstractEditorDelegate<?, ?> d : delegateMap) {
+      if (d.isDirty()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   public boolean setConstraintViolations(
       Iterable<ConstraintViolation<?>> violations) {
     return doSetViolations(SimpleViolation.iterableFromConstrantViolations(violations));
diff --git a/user/src/com/google/gwt/requestfactory/client/testing/MockRequestFactoryEditorDriver.java b/user/src/com/google/gwt/requestfactory/client/testing/MockRequestFactoryEditorDriver.java
index e7bf389..f870386 100644
--- a/user/src/com/google/gwt/requestfactory/client/testing/MockRequestFactoryEditorDriver.java
+++ b/user/src/com/google/gwt/requestfactory/client/testing/MockRequestFactoryEditorDriver.java
@@ -142,6 +142,13 @@
   }
 
   /**
+   * Returns {@code false}.
+   */
+  public boolean isDirty() {
+    return false;
+  }
+
+  /**
    * A no-op method that always returns false.
    */
   public boolean setConstraintViolations(
diff --git a/user/test/com/google/gwt/editor/client/SimpleBeanEditorTest.java b/user/test/com/google/gwt/editor/client/SimpleBeanEditorTest.java
index b2e55bd..0dec642 100644
--- a/user/test/com/google/gwt/editor/client/SimpleBeanEditorTest.java
+++ b/user/test/com/google/gwt/editor/client/SimpleBeanEditorTest.java
@@ -118,6 +118,19 @@
       SimpleBeanEditorDriver<Person, PersonEditorWithCoAddressEditorView> {
   }
 
+  class PersonEditorWithDelegate extends PersonEditor implements
+      HasEditorDelegate<Person> {
+    EditorDelegate<Person> delegate;
+
+    public void setDelegate(EditorDelegate<Person> delegate) {
+      this.delegate = delegate;
+    }
+  }
+
+  interface PersonEditorWithDelegateDriver extends
+      SimpleBeanEditorDriver<Person, PersonEditorWithDelegate> {
+  }
+
   class PersonEditorWithLeafAddressEditor implements Editor<Person> {
     LeafAddressEditor addressEditor = new LeafAddressEditor();
     SimpleEditor<String> name = SimpleEditor.of(UNINITIALIZED);
@@ -304,6 +317,76 @@
     assertEquals("Should see this", person.getName());
   }
 
+  public void testDirty() {
+    PersonEditor editor = new PersonEditor();
+    PersonEditorDriver driver = GWT.create(PersonEditorDriver.class);
+    driver.initialize(editor);
+    driver.edit(person);
+
+    // Freshly-initialized should not be dirty
+    assertFalse(driver.isDirty());
+
+    // Changing the Person object should not affect the dirty status
+    person.setName("blah");
+    assertFalse(driver.isDirty());
+
+    editor.addressEditor.city.setValue("Foo");
+    assertTrue(driver.isDirty());
+
+    // Reset to original value
+    editor.addressEditor.city.setValue("City");
+    assertFalse(driver.isDirty());
+
+    // Try a null value
+    editor.managerName.setValue(null);
+    assertTrue(driver.isDirty());
+  }
+
+  public void testDirtyWithDelegate() {
+    PersonEditorWithDelegate editor = new PersonEditorWithDelegate();
+    PersonEditorWithDelegateDriver driver = GWT.create(PersonEditorWithDelegateDriver.class);
+    driver.initialize(editor);
+    driver.edit(person);
+
+    // Freshly-initialized should not be dirty
+    assertFalse(driver.isDirty());
+
+    // Use the delegate to toggle the state
+    editor.delegate.setDirty(true);
+    assertTrue(driver.isDirty());
+    
+    // Use the delegate to clear the state
+    editor.delegate.setDirty(false);
+    assertFalse(driver.isDirty());
+
+    // Check that the delegate has no influence over values
+    editor.addressEditor.city.setValue("edited");
+    assertTrue(driver.isDirty());
+    editor.delegate.setDirty(false);
+    assertTrue(driver.isDirty());
+    editor.delegate.setDirty(true);
+    assertTrue(driver.isDirty());
+  }
+
+  public void testDirtyWithOptionalEditor() {
+    AddressEditor addressEditor = new AddressEditor();
+    PersonEditorWithOptionalAddressEditor editor = new PersonEditorWithOptionalAddressEditor(addressEditor);
+    PersonEditorWithOptionalAddressDriver driver = GWT.create(PersonEditorWithOptionalAddressDriver.class);
+    driver.initialize(editor);
+    driver.edit(person);
+    
+    // Freshly-initialized should not be dirty
+    assertFalse(driver.isDirty());
+    
+    // Change the instance being edited
+    Address a = new Address();
+    editor.address.setValue(a);
+    assertTrue(driver.isDirty());
+    
+    // Check restoration works
+    editor.address.setValue(personAddress);
+    assertFalse(driver.isDirty());
+  }
   /**
    * Test the use of the IsEditor interface that allows a view object to
    * encapsulate its Editor.