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

Proposed by Richard Harding
Status: Superseded
Proposed branch: lp:~rharding/launchpad/bugfix_891735
Merge into: lp:launchpad
Diff against target: 475 lines (+431/-1)
4 files modified
lib/lp/app/javascript/formwidgets/resizing_textarea.js (+231/-0)
lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.html (+32/-0)
lib/lp/app/javascript/formwidgets/tests/test_resizing_textarea.js (+158/-0)
lib/lp/bugs/javascript/filebug_dupefinder.js (+10/-1)
To merge this branch: bzr merge lp:~rharding/launchpad/bugfix_891735
Reviewer Review Type Date Requested Status
Aaron Bentley Pending
Review via email: mp+83068@code.launchpad.net

This proposal has been superseded by a proposal from 2011-11-23.

Commit message

Add new lp.app.formwidget.resizing_textarea to implement an auto resizing textarea widget for all form use
Add this new widget to the new bug process on the +filebug url
Add tests for the use cases of this working through the common settings and use cases

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.

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.

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

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 :

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.

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