← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rharding/launchpad/bugfix_891735 into lp:launchpad

 

Richard Harding has proposed merging lp:~rharding/launchpad/bugfix_891735 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #891735 in Launchpad itself: "add a javascript widget for enabling auto expanding textarea inputs"
  https://bugs.launchpad.net/launchpad/+bug/891735

For more details, see:
https://code.launchpad.net/~rharding/launchpad/bugfix_891735/+merge/83068

= Summary =
Add a new auto resizing textarea widget for launchpad

== Proposed fix ==
Added a new module under the lp.app.formwidgets namespace called ResizingTextarea. This is a YUI plugin and can be stuck on any textarea YUI node instances.

== Pre-implementation notes ==
Attempted to use a YUI Gallery module that lacked features and performed poorly. Talked with Deryck and we agreed to work on our own module. 

Took a peek at the inline editor module, and it does some of the same thing, but it's not modular enough to just use for simple textareas like the initial sample case of the new bug submitting form.

== Implementation details ==
The new plugin uses a YUI specific "valueChanged" event for detecting if the content of an input has changed by any means (pasting, key stroke, etc). 

It removes the ability for webkit browsers to resize the textarea since that can throw off the calculations of the size of the textarea.

It accepts parameters for min and max heights to help duplicate the functionality of defining rows in a textarea.

The adding and removing of rows of space are animated via a css3 transition. It should gracefull degrade on older browsers. There is a flag for turning off all animations. That's used to help keep tests from taking too long to run.

== Tests ==
./bin/test -cvvt test_resizing_textarea.html
$browser lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html

== Demo and Q/A ==
The tests are the main Q/A right now. The single point where it's created is when adding a new bug. So go to: 
https://bugs.launchpad.dev/launchpad/+filebug

Enter a summary, the "Further information" textarea should be an instance of ResizingTextarea and as you enter new lines it will start to expand. There's a default 450px height so at that point, the scrollbar should show up and begin to lengthen.
-- 
https://code.launchpad.net/~rharding/launchpad/bugfix_891735/+merge/83068
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rharding/launchpad/bugfix_891735 into lp:launchpad.
=== added file 'lib/lp/app/javascript/formwidgets/resizing_textarea.js'
--- lib/lp/app/javascript/formwidgets/resizing_textarea.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/formwidgets/resizing_textarea.js	2011-11-22 20:16:28 +0000
@@ -0,0 +1,225 @@
+/**
+ * Copyright 2011 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Auto Resizing Textarea Widget.
+ *
+ * Usage:
+ *     Y.one('#myid').plug(ResizingTextarea);
+ *     Y.one('#settings').plug(ResizingTextarea, {
+ *         min_height: 100
+ *     });
+ *
+ *     Y.all('textarea').plug(ResizingTextarea);
+ *
+ * @module lp.app.formwidgets
+ * @submodule resizing_textarea
+ */
+YUI.add('lp.app.formwidgets.resizing_textarea', function(Y) {
+
+var ns = Y.namespace("lp.app.formwidgets"),
+    ResizingTextarea = function(cfg) {
+        ResizingTextarea.superclass.constructor.apply(this, arguments);
+    };
+
+ResizingTextarea.NAME = "resizing_textarea";
+ResizingTextarea.NS = "resizing_textarea";
+
+/**
+ * ATTRS you can set on initialization to determine how we size the textarea
+ *
+ */
+ResizingTextarea.ATTRS = {
+    /**
+     * Min height to allow the textarea to shrink to in px
+     *
+     * @property min_height
+     *
+     */
+    min_height: {value: null},
+
+    /**
+     * Max height to allow the textarea to grow to in px
+     *
+     * @property max_height
+     *
+     */
+    max_height: {value: null},
+
+    /**
+     * Should we bypass animating changes in height
+     * Mainly used to turn off for testing to prevent needing to set timeouts
+     *
+     * @property skip_animations
+     *
+     */
+    skip_animations: {value: false}
+};
+
+Y.extend(ResizingTextarea, Y.Plugin.Base, {
+
+    // special css we add to clones to make sure they're hidden from view
+    CLONE_CSS: {
+        position: 'absolute',
+        top: -9999,
+        left: -9999,
+        opacity: 0,
+        overflow: 'hidden',
+        resize: 'none'
+    },
+
+    // defaults for sizing settings in case they're not supplied
+    MIN_HEIGHT: 10,
+    MAX_HEIGHT: 450,
+
+    // used to track if we're growing/shrinking for each event fired
+    _prev_scroll_height: 0,
+
+    _bind_events: function () {
+        // look at adjusting the size on any value change event including
+        // pasting and such
+        this.t_area.on('valueChange', function(e) {
+            // we need to update the clone with the content so it resizes
+            this.clone.set('text', e.newVal);
+            this.resize();
+        }, this);
+    },
+
+    /**
+     * If not default height specified, check for html based default height
+     *
+     * If there's no default passed into the config and no html based default,
+     * use our own constants for it
+     *
+     */
+    _set_start_height: function () {
+        if (! this.get('min_height')) {
+            this.set('min_height',
+                     parseInt(this.t_area.getStyle('height'),
+                     this.MIN_HEIGHT));
+        }
+
+        if (! this.get('max_height')) {
+            this.set('max_height', this.MAX_HEIGHT);
+        }
+
+        // we want to start out saying we're at our minimum size
+        this._prev_scroll_height = this.get('min_height');
+    },
+
+    /**
+     * Given a node, setup a clone so that we can use it for sizing
+     *
+     * We need to copy this, move it way off the screen and setup some css we
+     * use to make sure that we match the original as best as possible.
+     *
+     * This clone is then checked for the size to use
+     *
+     */
+    _setup_clone: function (node) {
+        var clone = node.cloneNode(true);
+
+        clone.setStyles(this.CLONE_CSS);
+        // remove attributes so we don't accidentally grab this node in the
+        // future
+        clone.removeAttribute('id');
+        clone.removeAttribute('name');
+        clone.generateID();
+        clone.setAttrs({
+            'tabIndex': -1,
+            'height': 'auto'
+        });
+        Y.one('body').append(clone);
+
+        return clone;
+    },
+
+    /**
+     * We need to apply some special css to our target we want to resize
+     *
+     */
+    _setup_css: function (target) {
+        // don't let this text area be resized in webkit, it'll mess with our
+        // calcs and we'll be fighting the whole time for the right size
+        target.setStyle('resize', 'none');
+        target.setStyle('overflow', 'hidden');
+
+        // we want to add some animation to our adjusting of the size, using
+        // css animation to smooth all height changes
+        if (!this.get('skip_animations')) {
+            target.setStyle('transition', 'height 0.3s ease');
+            target.setStyle('-webkit-transition', 'height 0.3s ease');
+            target.setStyle('-moz-transition', 'height 0.3s ease');
+        }
+    },
+
+    initializer : function(cfg) {
+        this.t_area = this.get("host");
+        this._setup_css(this.t_area);
+
+        // we need to setup the clone of this node so we can check how big it
+        // is, but way off the screen so you don't see it
+        this.clone = this._setup_clone(this.t_area);
+
+        this._set_start_height();
+        this._bind_events();
+
+        // initial sizing in case there's existing content to match to
+        this.resize();
+    },
+
+    /**
+     * Adjust the size of the textarea as needed
+     *
+     * @method resize
+     *
+     */
+    resize: function() {
+        // start out just making the hieght the same as the clone's scroll
+        // height
+        var scroll_height = this.clone.get('scrollHeight');
+
+        if (scroll_height > this._prev_scroll_height &&
+            scroll_height > this.get('min_height')) {
+            this.t_area.setStyle('height',
+                                 Math.min(scroll_height, this.get('max_height')
+            ));
+        } else if (scroll_height > this.get('max_height')) {
+            // a corner case, when we have a lot of text, and we delete some,
+            // the scroll height decreases, signalling we should shrink the
+            // box, but since we're still larger than the max set...ignore the
+            // rule and don't change a thing
+        } else {
+            this.t_area.setStyle('height',
+                                 Math.max(scroll_height, this.get('min_height')
+            ));
+        }
+
+        // check if the changes above require us to change our overflow setting
+        // to allow for a scrollbar now that our max size has been reached
+        this.set_overflow();
+
+        this._prev_scroll_height = scroll_height;
+    },
+
+    /**
+     * Check if we're larger than the max_height setting and enable scrollbar
+     *
+     * @method set_overflow
+     *
+     */
+    set_overflow: function() {
+        var overflow = "hidden";
+        if (this.clone.get('scrollHeight') >= this.get('max_height')) {
+            overflow = "auto";
+        }
+        this.t_area.setStyle('overflow', overflow);
+    }
+});
+
+// add onto the formwidget namespace
+ns.ResizingTextarea = ResizingTextarea;
+
+}, "0.1", {
+    "requires": ["plugin", "node", "event-valuechange"]
+});

=== added file 'lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html'
--- lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html	2011-11-22 20:16:28 +0000
@@ -0,0 +1,30 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Resizing Textarea Plugin</title>
+
+  <!-- YUI and test setup -->
+  <script type="text/javascript"
+          src="../../../../../canonical/launchpad/icing/yui/yui/yui.js">
+  </script>
+  <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+  <script type="text/javascript"
+          src="../../../../app/javascript/testing/testrunner.js"></script>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../resizing_textarea.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="test_resizing_textarea.js"></script>
+
+</head>
+<body class="yui3-skin-sam">
+    <!-- We're going to test interacting with dom elements, so let's have some -->
+    <textarea id="init">Initial text</textarea>
+    <textarea id="with_defaults">has defaults</textarea>
+    <textarea id="shrinkage"></textarea>
+    <textarea id="multiple1" class="test_multiple first"></textarea>
+    <textarea id="multiple2" class="test_multiple second"></textarea>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js'
--- lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js	2011-11-22 20:16:28 +0000
@@ -0,0 +1,176 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lp.testing.runner', 'test', 'console', 'node', 'event',
+          'node-event-simulate', 'event-valuechange', 'plugin',
+          'lp.app.formwidgets.resizing_textarea', function(Y) {
+
+var Assert = Y.Assert,  // For easy access to isTrue(), etc.
+    CHANGE_TIME = 100;    // we need a slight delay for the reize to happen
+
+var test_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas ut viverra nibh. Morbi sit amet tellus accumsan justo rutrum blandit sit amet ac augue. Pellentesque eget diam at purus suscipit venenatis. Proin non neque lacus. Curabitur venenatis tempus sem, vitae porttitor magna fringilla vel. Cras dignissim egestas lacus nec hendrerit. Proin pharetra, felis ac auctor dapibus, neque orci commodo lorem, sit amet posuere erat quam euismod arcu. Nulla pharetra augue at enim tempus faucibus. Sed dictum tristique nisl sed rhoncus. Etiam tristique nisl eget risus blandit iaculis. Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
+
+/**
+ * Helper function to turn the string from getComputedStyle to int
+ *
+ */
+function clean_size(val) {
+    return parseInt(val.replace('px', ''), 10);
+}
+
+/**
+ * Helper to extract the computed height of the element
+ *
+ */
+function get_height(target) {
+    return clean_size(target.getComputedStyle('height'));
+}
+
+/**
+ * In order to update the content we need to change the text, but also to fire
+ * the event that the content has changed since we're modifying it
+ * programatically
+ *
+ */
+function update_content(target, val) {
+    target.set('value', val);
+    target.simulate('keydown', {keycode: 97});
+    target.simulate('keyup', {keycode: 97});
+}
+
+/**
+ * When the box resizes, it needs a slight sec to make the change
+ *
+ * We need to pause and all tests should do this so we wrap it
+ *
+ */
+function assert_change(callback) {
+    setTimeout(callback, CHANGE_TIME);
+}
+
+
+var suite = new Y.Test.Suite("Resizing Textarea Tests");
+
+suite.add(new Y.Test.Case({
+
+    name: 'resizing_textarea',
+
+    test_initial_resizable: function() {
+        var that = this,
+            target = Y.one('#init');
+
+        Assert.areEqual('Initial text', target.get('value'));
+
+        target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
+            skip_animations: true
+        });
+
+        // get the current sizes so we can pump text into it and make sure it
+        // grows
+        var orig_height = get_height(target);
+        update_content(target, test_text);
+
+        // we have to wait for the resize
+        that.wait(function () {
+            var new_height = get_height(target);
+            Assert.isTrue(new_height > orig_height,
+                "The height should increase with content");
+        }, CHANGE_TIME);
+
+    },
+
+    test_max_height: function () {
+        var that = this,
+            target = Y.one('#with_defaults');
+
+        target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
+            skip_animations: true,
+            max_height: 200,
+            min_height: 100
+        }, CHANGE_TIME);
+
+        that.wait(function () {
+            var min_height = get_height(target);
+            Assert.isTrue(min_height === 100,
+                "The height should be no smaller than 100px");
+        }, CHANGE_TIME);
+
+        update_content(target, test_text);
+
+        // we have to wait for the resize
+        that.wait(function () {
+            var new_height = get_height(target);
+            Assert.isTrue(new_height === 200,
+                "The height should only get to 200px");
+        }, CHANGE_TIME);
+    },
+
+    test_removing_content: function () {
+        var that = this,
+            target = Y.one('#shrinkage');
+
+        target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
+            skip_animations: true,
+            min_height: 100
+        });
+
+        update_content(target, test_text);
+        that.wait(function () {
+            var max_height = get_height(target);
+            Assert.isTrue(max_height > 100,
+                "The height should be larger than our min with content");
+        }, CHANGE_TIME);
+
+        update_content(target, "shrink");
+
+        that.wait(function () {
+            var min_height = get_height(target);
+            Assert.isTrue(min_height === 100,
+                "The height should shrink back to our min");
+        }, CHANGE_TIME);
+    },
+
+    test_multiple: function () {
+        var that = this,
+            target = Y.all('.test_multiple');
+
+        target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
+            skip_animations: true,
+            min_height: 100
+        });
+
+        that.wait(function () {
+            target.each(function (n) {
+                var min_height = get_height(n);
+                Assert.isTrue(min_height === 100,
+                    "The height of the node should be 100");
+            });
+        }, CHANGE_TIME);
+
+        // now set the content in the first one and check it's unique
+        update_content(Y.one('.first'), test_text);
+
+        that.wait(function () {
+            var first = Y.one('.first'),
+                second = Y.one('.second');
+
+            var first_height = get_height(first);
+            Assert.isTrue(first_height > 100,
+                "The height of the first should now be > 100");
+
+            var second_height = get_height(second);
+            Assert.isTrue(second === 100,
+                "The height of the second should still be > 100");
+        }, CHANGE_TIME);
+    }
+}));
+
+var yconsole = new Y.Console({
+    newestOnTop: false
+});
+yconsole.render('#log');
+
+Y.on('domready', function (e) {
+    Y.lp.testing.Runner.run(suite);
+});
+
+});

=== modified file 'lib/lp/bugs/javascript/filebug_dupefinder.js'
--- lib/lp/bugs/javascript/filebug_dupefinder.js	2011-07-18 15:07:40 +0000
+++ lib/lp/bugs/javascript/filebug_dupefinder.js	2011-11-22 20:16:28 +0000
@@ -197,6 +197,13 @@
             show_bug_reporting_form();
         }
 
+        // now we need to wire up the text expander after we load our textarea
+        // onto the page
+        Y.one("#bug-reporting-form textarea").plug(
+                Y.lp.app.formwidgets.ResizingTextarea, {
+                min_height: 300
+        });
+
         // Copy the value from the search field into the title field
         // on the filebug form.
         Y.one('#bug-reporting-form input[name=field.title]').set(
@@ -346,6 +353,7 @@
         // confuse the view when we submit a bug report.
         search_field.set('name', 'field.search');
         search_field.set('id', 'field.search');
+
         // Set up the handler for the search form.
         var search_form = Y.one('#filebug-search-form');
         search_form.on('submit', function(e) {
@@ -462,6 +470,7 @@
         config = {on: {success: set_up_dupe_finder,
                        failure: function() {}}};
 
+
         // Load the filebug form asynchronously. If this fails we
         // degrade to the standard mode for bug filing, clicking through
         // to the second part of the bug filing form.
@@ -487,4 +496,4 @@
 
 }, "0.1", {"requires": [
     "base", "io", "oop", "node", "event", "json", "lazr.formoverlay",
-    "lazr.effects", "lp.app.widgets.expander"]});
+    "lazr.effects", "lp.app.widgets.expander", "lp.app.formwidgets.resizing_textarea", "plugin"]});