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

Proposed by Richard Harding
Status: Merged
Approved by: Aaron Bentley
Approved revision: no longer in the source branch.
Merged at revision: 14379
Proposed branch: lp:~rharding/launchpad/bugfix_891735
Merge into: lp:launchpad
Diff against target: 470 lines (+422/-1)
5 files modified
lib/lp/app/javascript/formwidgets/resizing_textarea.js (+209/-0)
lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html (+32/-0)
lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js (+166/-0)
lib/lp/bugs/javascript/filebug_dupefinder.js (+14/-1)
lib/lp/bugs/javascript/tests/test_filebug_dupfinder.html (+1/-0)
To merge this branch: bzr merge lp:~rharding/launchpad/bugfix_891735
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+83217@code.launchpad.net

This proposal supersedes a proposal from 2011-11-22.

Commit message

[r=abentley][bug=891735] create new YUI plugin for auto resizing textarea ui widgets to the formwidget library and add the new widget to the bug report comment textarea

Description of the change

= 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. Deryck pointed me towards the functionality in the inline edit module and to check if we could use that.

I 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. It's also less missing some types of changes into the textarea and missing features such as the min and max height code.

Discussed with Deryck and abently and decided that this can proceed, with the understanding that the next goal will be refactoring the inline edit module to use this plugin as a means to manage its own auto resizing behavior. The inline edit will then only need to worry about button, save/cancel events, and such.

== 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 browsers feature for users to resize the textarea via the corner grab point 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.

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote : Posted in a previous version of this proposal

I'm confused - the bug description editing field already autosizes
(poorly, but it does) - is that not widget already?

Revision history for this message
Deryck Hodge (deryck) wrote : Posted in a previous version of this proposal

The inline editor widget does resizing as one of among a hundred other things. :) The inline editor's resizing is not reusable at all. For one, the multi-line editor which uses text areas is bound to the same widget for editing single-line text inputs. So the current editor widget is poorly designed and broken in lots of little ways.

I suggested Rick add this current work as a way to plug text areas, thinking it would give us a generic widget for re-use across Launchpad for this pattern, and also give us something to change out to for the resizing bits in the inline editor.

I think fixing the inline editor at the same time as adding this widget is too much. The inline editor is a lot work by itself.

Revision history for this message
Aaron Bentley (abentley) wrote :

Rick will be refactoring the inline editor, so the functionality duplication should be short-term.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'lib/lp/app/javascript/formwidgets/resizing_textarea.js'
2--- lib/lp/app/javascript/formwidgets/resizing_textarea.js 1970-01-01 00:00:00 +0000
3+++ lib/lp/app/javascript/formwidgets/resizing_textarea.js 2011-11-24 03:57:28 +0000
4@@ -0,0 +1,209 @@
5+/**
6+ * Copyright 2011 Canonical Ltd. This software is licensed under the
7+ * GNU Affero General Public License version 3 (see the file LICENSE).
8+ *
9+ * Auto Resizing Textarea Widget.
10+ *
11+ * Usage:
12+ * Y.one('#myid').plug(ResizingTextarea);
13+ * Y.one('#settings').plug(ResizingTextarea, {
14+ * min_height: 100
15+ * });
16+ *
17+ * Y.all('textarea').plug(ResizingTextarea);
18+ *
19+ * @module lp.app.formwidgets
20+ * @submodule resizing_textarea
21+ */
22+YUI.add('lp.app.formwidgets.resizing_textarea', function(Y) {
23+
24+var module = Y.namespace("lp.app.formwidgets"),
25+ ResizingTextarea = function(cfg) {
26+ ResizingTextarea.superclass.constructor.apply(this, arguments);
27+ };
28+
29+ResizingTextarea.NAME = "resizing_textarea";
30+ResizingTextarea.NS = "resizing_textarea";
31+
32+/**
33+ * ATTRS you can set on initialization to determine how we size the textarea
34+ */
35+ResizingTextarea.ATTRS = {
36+ /**
37+ * Min height to allow the textarea to shrink to in px
38+ *
39+ * We check if there's a css rule for existing height and make that the
40+ * min height in case it's there
41+ *
42+ * @property min_height
43+ */
44+ min_height: {
45+ value: 10,
46+
47+ valueFn: function () {
48+ var target = this.get("host");
49+ var css_height = target.getStyle('height');
50+
51+ return !Y.Lang.isUndefined(css_height) ?
52+ this._clean_size(css_height) : undefined;
53+ }
54+ },
55+
56+ /**
57+ * Max height to allow the textarea to grow to in px
58+ *
59+ * @property max_height
60+ */
61+ max_height: {
62+ value: 450
63+ },
64+
65+ /**
66+ * Should we bypass animating changes in height
67+ * Mainly used to turn off for testing to prevent needing to set timeouts
68+ *
69+ * @property skip_animations
70+ */
71+ skip_animations: {value: false}
72+};
73+
74+Y.extend(ResizingTextarea, Y.Plugin.Base, {
75+
76+ // special css we add to clones to make sure they're hidden from view
77+ CLONE_CSS: {
78+ position: 'absolute',
79+ top: -9999,
80+ left: -9999,
81+ opacity: 0,
82+ overflow: 'hidden',
83+ resize: 'none'
84+ },
85+
86+ /**
87+ * Helper function to turn the string from getComputedStyle to int
88+ */
89+ _clean_size: function (val) {
90+ return parseInt(val.replace('px', ''), 10);
91+ },
92+
93+ // used to track if we're growing/shrinking for each event fired
94+ _prev_scroll_height: 0,
95+
96+ /**
97+ * This is the entry point for the event of change
98+ *
99+ * Here we update the clone and resize based on the update
100+ */
101+ _run_change: function (new_value) {
102+ // we need to update the clone with the content so it resizes
103+ this.clone.set('text', new_value);
104+ this.resize();
105+ },
106+
107+ /**
108+ * Given a node, setup a clone so that we can use it for sizing
109+ *
110+ * We need to copy this, move it way off the screen and setup some css we
111+ * use to make sure that we match the original as best as possible.
112+ *
113+ * This clone is then checked for the size to use
114+ */
115+ _setup_clone: function (node) {
116+ var clone = node.cloneNode(true);
117+
118+ clone.setStyles(this.CLONE_CSS);
119+ // remove attributes so we don't accidentally grab this node in the
120+ // future
121+ clone.removeAttribute('id');
122+ clone.removeAttribute('name');
123+ clone.generateID();
124+ clone.setAttrs({
125+ 'tabIndex': -1,
126+ 'height': 'auto'
127+ });
128+ Y.one('body').append(clone);
129+
130+ return clone;
131+ },
132+
133+ /**
134+ * We need to apply some special css to our target we want to resize
135+ */
136+ _setup_css: function () {
137+ // don't let this text area be resized in the browser, it'll mess with
138+ // our calcs and we'll be fighting the whole time for the right size
139+ this.t_area.setStyle('resize', 'none');
140+ this.t_area.setStyle('overflow', 'hidden');
141+
142+ // we want to add some animation to our adjusting of the size, using
143+ // css animation to smooth all height changes
144+ if (!this.get('skip_animations')) {
145+ this.t_area.setStyle('transition', 'height 0.3s ease');
146+ this.t_area.setStyle('-webkit-transition', 'height 0.3s ease');
147+ this.t_area.setStyle('-moz-transition', 'height 0.3s ease');
148+ }
149+ },
150+
151+ initializer : function(cfg) {
152+ this.t_area = this.get("host");
153+ this._setup_css(this.t_area);
154+
155+ // we need to setup the clone of this node so we can check how big it
156+ // is, but way off the screen so you don't see it
157+ this.clone = this._setup_clone(this.t_area);
158+
159+ // look at adjusting the size on any value change event including
160+ // pasting and such
161+ this.t_area.on('valueChange', function(e) {
162+ this._run_change(e.newVal);
163+ }, this);
164+
165+ // initial sizing in case there's existing content to match to
166+ this.resize();
167+ },
168+
169+ /**
170+ * Adjust the size of the textarea as needed
171+ *
172+ * @method resize
173+ */
174+ resize: function() {
175+ var scroll_height = this.clone.get('scrollHeight');
176+
177+ // only update the height if we've changed
178+ if (this._prev_scroll_height !== scroll_height) {
179+ new_height = Math.max(
180+ this.get('min_height'),
181+ Math.min(scroll_height, this.get('max_height')));
182+
183+ this.t_area.setStyle('height', new_height);
184+
185+ // check if the changes above require us to change our overflow
186+ // setting to allow for a scrollbar now that our max size has been
187+ // reached
188+ this.set_overflow();
189+
190+ this._prev_scroll_height = scroll_height;
191+ }
192+ },
193+
194+ /**
195+ * Check if we're larger than the max_height setting and enable scrollbar
196+ *
197+ * @method set_overflow
198+ */
199+ set_overflow: function() {
200+ var overflow = "hidden";
201+ if (this.clone.get('scrollHeight') >= this.get('max_height')) {
202+ overflow = "auto";
203+ }
204+ this.t_area.setStyle('overflow', overflow);
205+ }
206+});
207+
208+// add onto the formwidget namespace
209+module.ResizingTextarea = ResizingTextarea;
210+
211+}, "0.1", {
212+ "requires": ["plugin", "node", "event-valuechange"]
213+});
214
215=== added file 'lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html'
216--- lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html 1970-01-01 00:00:00 +0000
217+++ lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html 2011-11-24 03:57:28 +0000
218@@ -0,0 +1,32 @@
219+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
220+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
221+<html>
222+ <head>
223+ <title>Resizing Textarea Plugin</title>
224+
225+ <!-- YUI and test setup -->
226+ <script type="text/javascript"
227+ src="../../../../../canonical/launchpad/icing/yui/yui/yui.js">
228+ </script>
229+ <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
230+ <script type="text/javascript"
231+ src="../../../../app/javascript/testing/testrunner.js"></script>
232+
233+ <!-- The module under test -->
234+ <script type="text/javascript" src="../resizing_textarea.js"></script>
235+
236+ <!-- The test suite -->
237+ <script type="text/javascript" src="test_resizing_textarea.js"></script>
238+
239+</head>
240+<body class="yui3-skin-sam">
241+ <!-- We're going to test interacting with dom elements, so let's have some -->
242+ <textarea id="init">Initial text</textarea>
243+ <textarea id="with_defaults">has defaults</textarea>
244+ <textarea id="shrinkage"></textarea>
245+ <textarea id="multiple1" class="test_multiple first"></textarea>
246+ <textarea id="multiple2" class="test_multiple second"></textarea>
247+ <textarea id="css_height" style="height: 120px;"></textarea>
248+</body>
249+
250+</html>
251
252=== added file 'lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js'
253--- lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js 1970-01-01 00:00:00 +0000
254+++ lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js 2011-11-24 03:57:28 +0000
255@@ -0,0 +1,166 @@
256+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
257+
258+YUI().use('lp.testing.runner', 'test', 'console', 'node', 'event',
259+ 'node-event-simulate', 'event-valuechange', 'plugin',
260+ 'lp.app.formwidgets.resizing_textarea', function(Y) {
261+
262+
263+var test_text = ["Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
264+ "Maecenas ut viverra nibh. Morbi sit amet tellus accumsan justo rutrum",
265+ "blandit sit amet ac augue. Pellentesque eget diam at purus suscipit",
266+ "venenatis. Proin non neque lacus. Curabitur venenatis tempus sem, vitae",
267+ "porttitor magna fringilla vel. Cras dignissim egestas lacus nec",
268+ "hendrerit. Proin pharetra, felis ac auctor dapibus, neque orci commodo ",
269+ "lorem, sit amet posuere erat quam euismod arcu. Nulla pharetra augue at",
270+ "enim tempus faucibus. Sed dictum tristique nisl sed rhoncus. Etiam ",
271+ "tristique nisl eget risus blandit iaculis. Lorem ipsum dolor sit amet,",
272+ "consectetur adipiscing elit."].join("");
273+
274+/**
275+ * Helper function to turn the string from getComputedStyle to int
276+ *
277+ */
278+function clean_size(val) {
279+ return parseInt(val.replace('px', ''), 10);
280+}
281+
282+/**
283+ * Helper to extract the computed height of the element
284+ *
285+ */
286+function get_height(target) {
287+ return clean_size(target.getComputedStyle('height'));
288+}
289+
290+/**
291+ * In order to update the content we need to change the text, but also to fire
292+ * the event that the content has changed since we're modifying it
293+ * programatically
294+ *
295+ */
296+function update_content(target, val) {
297+ target.set('value', val);
298+
299+ // instead of hitting the changed event directly, we'll just manually call
300+ // into the hook for the event itself
301+ target.resizing_textarea._run_change(val);
302+}
303+
304+var suite = new Y.Test.Suite("Resizing Textarea Tests");
305+
306+suite.add(new Y.Test.Case({
307+
308+ name: 'resizing_textarea',
309+
310+ test_initial_resizable: function() {
311+ var target = Y.one('#init');
312+
313+ Y.Assert.areEqual('Initial text', target.get('value'));
314+
315+ target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
316+ skip_animations: true
317+ });
318+
319+ // get the current sizes so we can pump text into it and make sure it
320+ // grows
321+ var orig_height = get_height(target);
322+ update_content(target, test_text);
323+
324+ var new_height = get_height(target);
325+ Y.Assert.isTrue(new_height > orig_height,
326+ "The height should increase with content");
327+
328+ },
329+
330+ test_max_height: function () {
331+ var target = Y.one('#with_defaults');
332+
333+ target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
334+ skip_animations: true,
335+ max_height: 200,
336+ min_height: 100
337+ });
338+
339+ var min_height = get_height(target);
340+ Y.Assert.areSame(100, min_height,
341+ "The height should be no smaller than 100px");
342+
343+ update_content(target, test_text);
344+
345+ var new_height = get_height(target);
346+ Y.Assert.areSame(200, new_height,
347+ "The height should only get to 200px");
348+ },
349+
350+ test_removing_content: function () {
351+ var target = Y.one('#shrinkage');
352+
353+ target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
354+ skip_animations: true,
355+ min_height: 100
356+ });
357+
358+ update_content(target, test_text);
359+ var max_height = get_height(target);
360+ Y.Assert.isTrue(max_height > 100,
361+ "The height should be larger than our min with content");
362+
363+ update_content(target, "shrink");
364+
365+ var min_height = get_height(target);
366+ Y.Assert.areSame(100, min_height,
367+ "The height should shrink back to our min");
368+ },
369+
370+ test_multiple: function () {
371+ var target = Y.all('.test_multiple');
372+
373+ target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
374+ skip_animations: true,
375+ min_height: 100
376+ });
377+
378+ target.each(function (node) {
379+ var min_height = get_height(node);
380+ Y.Assert.areSame(100, min_height,
381+ "The height of the node should be 100");
382+ });
383+
384+ // now set the content in the first one and check it's unique
385+ update_content(Y.one('.first'), test_text);
386+
387+ var first = Y.one('.first');
388+ var second = Y.one('.second');
389+
390+ var first_height = get_height(first);
391+ Y.Assert.isTrue(first_height > 100,
392+ "The height of the first should now be > 100");
393+
394+ var second_height = get_height(second);
395+ Y.Assert.areSame(100, second_height,
396+ "The height of the second should still be 100");
397+ },
398+
399+ test_css_height_preset: function () {
400+ var target = Y.one('#css_height');
401+
402+ target.plug(Y.lp.app.formwidgets.ResizingTextarea, {
403+ skip_animations: true
404+ });
405+
406+ var current_height = get_height(target);
407+ Y.Assert.areSame(120, current_height,
408+ "The height should match the css property at 120px");
409+ }
410+}));
411+
412+var yconsole = new Y.Console({
413+ newestOnTop: false
414+});
415+yconsole.render('#log');
416+
417+Y.on('load', function (e) {
418+ Y.lp.testing.Runner.run(suite);
419+});
420+
421+});
422
423=== modified file 'lib/lp/bugs/javascript/filebug_dupefinder.js'
424--- lib/lp/bugs/javascript/filebug_dupefinder.js 2011-07-18 15:07:40 +0000
425+++ lib/lp/bugs/javascript/filebug_dupefinder.js 2011-11-24 03:57:28 +0000
426@@ -197,6 +197,17 @@
427 show_bug_reporting_form();
428 }
429
430+ // now we need to wire up the text expander after we load our textarea
431+ // onto the page, but only if we find one on the page. In testing we
432+ // don't have one. We want to just fail silently.
433+ var textarea = Y.one("#bug-reporting-form textarea");
434+ if (textarea) {
435+ textarea.plug(
436+ Y.lp.app.formwidgets.ResizingTextarea, {
437+ min_height: 300
438+ });
439+ }
440+
441 // Copy the value from the search field into the title field
442 // on the filebug form.
443 Y.one('#bug-reporting-form input[name=field.title]').set(
444@@ -346,6 +357,7 @@
445 // confuse the view when we submit a bug report.
446 search_field.set('name', 'field.search');
447 search_field.set('id', 'field.search');
448+
449 // Set up the handler for the search form.
450 var search_form = Y.one('#filebug-search-form');
451 search_form.on('submit', function(e) {
452@@ -487,4 +499,5 @@
453
454 }, "0.1", {"requires": [
455 "base", "io", "oop", "node", "event", "json", "lazr.formoverlay",
456- "lazr.effects", "lp.app.widgets.expander"]});
457+ "lazr.effects", "lp.app.widgets.expander",
458+ "lp.app.formwidgets.resizing_textarea", "plugin"]});
459
460=== modified file 'lib/lp/bugs/javascript/tests/test_filebug_dupfinder.html'
461--- lib/lp/bugs/javascript/tests/test_filebug_dupfinder.html 2011-07-08 06:06:15 +0000
462+++ lib/lp/bugs/javascript/tests/test_filebug_dupfinder.html 2011-11-24 03:57:28 +0000
463@@ -18,6 +18,7 @@
464 <!-- Some required dependencies -->
465 <script type="text/javascript" src="../../../app/javascript/effects/effects.js"></script>
466 <script type="text/javascript" src="../../../app/javascript/overlay/overlay.js"></script>
467+ <script type="text/javascript" src="../../../app/javascript/formwidgets/resizing_textarea.js"></script>
468
469 <!-- The module under test -->
470 <script type="text/javascript"