launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #04602
[Merge] lp:~adeuring/launchpad/bug-739052-5 into lp:launchpad
Abel Deuring has proposed merging lp:~adeuring/launchpad/bug-739052-5 into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~adeuring/launchpad/bug-739052-5/+merge/71548
This branch is another sequel to get StormRangeFactory running. A first test in a batch view revealed that timestamps used as memo values were not properly processed: I simply forgot before that we use timestamps with a timezone in our database.
So, instead of timestamps like "2011-08-16T12:13:14" we have values like "2011-08-16T12:13:14+00:00"
This branch fixes the method StormRangeFactory.parseMemo() to properly process a time zone offset.
Since the memo value comes "in real life" from a URL query string, i.e., we have no real control over how users might tweak them, I still allow timestamps without a time zone offset: In this case, it is assumed the a UTC time is "meant".
Another issue I discovered during the batch view test: lazr.batchnavigator calls len(slice), where slice is returned by StormRangeFactory.getSlice(). Since Storm ResultSet instances do not implement __len__(), getSlice() now returns lists.
test: ./bin/test canonical -vvt test_batching
no lint
--
https://code.launchpad.net/~adeuring/launchpad/bug-739052-5/+merge/71548
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~adeuring/launchpad/bug-739052-5 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/scripts/ftests/test_oops_prune.py'
=== modified file 'lib/canonical/launchpad/scripts/oops.py'
--- lib/canonical/launchpad/scripts/oops.py 2011-08-12 20:41:50 +0000
+++ lib/canonical/launchpad/scripts/oops.py 2011-08-15 11:58:31 +0000
@@ -20,7 +20,11 @@
import os
import re
+<<<<<<< TREE
from oops import uniquefileallocator
+=======
+from ...oops import uniquefileallocator
+>>>>>>> MERGE-SOURCE
from pytz import utc
from canonical.database.sqlbase import cursor
=== modified file 'lib/canonical/launchpad/webapp/batching.py'
--- lib/canonical/launchpad/webapp/batching.py 2011-08-08 11:48:18 +0000
+++ lib/canonical/launchpad/webapp/batching.py 2011-08-15 11:58:31 +0000
@@ -4,9 +4,11 @@
__metaclass__ = type
from datetime import datetime
+import re
import lazr.batchnavigator
from lazr.batchnavigator.interfaces import IRangeFactory
+import pytz
import simplejson
from storm import Undef
from storm.expr import (
@@ -161,6 +163,15 @@
return simplejson.JSONEncoder.default(self, obj)
+# An ISO timestamp has the format yyyy-mm-ddThh:mm:ss.ffffff+hh:mm
+# The fractions of a second and the time zone offset are optional.
+timestamp_regex = re.compile(
+ r'^(?P<year>\d\d\d\d)-(?P<month>\d\d)-(?P<day>\d\d)'
+ 'T(?P<hour>\d\d):(?P<minute>\d\d):(?P<second>\d\d)'
+ '(\.(?P<sec_fraction>\d\d\d\d\d\d))?'
+ '((?P<tzsign>[+-])(?P<tzhour>\d\d):(?P<tzminute>\d\d))?$')
+
+
class StormRangeFactory:
"""A range factory for Storm result sets.
@@ -302,21 +313,36 @@
# value.
if (str(error).startswith('Expected datetime') and
isinstance(value, str)):
+ mo = timestamp_regex.search(value)
+ if mo is None:
+ self.reportError(
+ 'Invalid datetime value: %r' % value)
+ return None
+ sec_fraction = mo.group('sec_fraction')
+ if sec_fraction is None:
+ sec_fraction = 0
+ else:
+ sec_fraction = int(sec_fraction)
+ tzsign = mo.group('tzsign')
+ if tzsign is None:
+ tzinfo = pytz.UTC
+ else:
+ tzhour = int(mo.group('tzhour'))
+ tzminute = int(mo.group('tzminute'))
+ tzoffset = 60 * tzhour + tzminute
+ if tzsign == '-':
+ tzoffset = -tzoffset
+ tzinfo = pytz.FixedOffset(tzoffset)
try:
- value = datetime.strptime(
- value, '%Y-%m-%dT%H:%M:%S.%f')
+ value = datetime(
+ int(mo.group('year')), int(mo.group('month')),
+ int(mo.group('day')), int(mo.group('hour')),
+ int(mo.group('minute')), int(mo.group('second')),
+ sec_fraction, tzinfo)
except ValueError:
- # One more attempt: If the fractions of a second
- # are zero, datetime.isoformat() omits the
- # entire part '.000000', so we need a different
- # format for strptime().
- try:
- value = datetime.strptime(
- value, '%Y-%m-%dT%H:%M:%S')
- except ValueError:
- self.reportError(
- 'Invalid datetime value: %r' % value)
- return None
+ self.reportError(
+ 'Invalid datetime value: %r' % value)
+ return None
else:
self.reportError(
'Invalid parameter: %r' % value)
@@ -473,7 +499,9 @@
if not forwards:
self.resultset.order_by(*self.reverseSortOrder())
parsed_memo = self.parseMemo(endpoint_memo)
+ # Note that lazr.batchnavigator calls len(slice), so we can't
+ # return the plain result set.
if parsed_memo is None:
- return self.resultset.config(limit=size)
+ return list(self.resultset.config(limit=size))
else:
- return self.getSliceFromMemo(size, parsed_memo)
+ return list(self.getSliceFromMemo(size, parsed_memo))
=== modified file 'lib/canonical/launchpad/webapp/errorlog.py'
=== modified file 'lib/canonical/launchpad/webapp/tests/test_batching.py'
--- lib/canonical/launchpad/webapp/tests/test_batching.py 2011-08-12 11:37:08 +0000
+++ lib/canonical/launchpad/webapp/tests/test_batching.py 2011-08-15 11:58:31 +0000
@@ -22,6 +22,7 @@
BatchNavigator,
DateTimeJSONEncoder,
StormRangeFactory,
+ timestamp_regex,
)
from canonical.launchpad.webapp.interfaces import StormRangeFactoryError
from canonical.launchpad.webapp.servers import LaunchpadTestRequest
@@ -276,34 +277,61 @@
resultset = self.makeStormResultSet()
resultset.order_by(Person.datecreated, Person.name, Person.id)
range_factory = StormRangeFactory(resultset, self.logError)
- valid_memo = [datetime(2011, 7, 25, 11, 30, 30, 45), 'foo', 1]
+ valid_memo = [
+ datetime(2011, 7, 25, 11, 30, 30, 45, tzinfo=pytz.UTC), 'foo', 1]
json_data = simplejson.dumps(valid_memo, cls=DateTimeJSONEncoder)
self.assertEqual(valid_memo, range_factory.parseMemo(json_data))
self.assertEqual(0, len(self.error_messages))
def test_parseMemo__short_iso_timestamp(self):
# An ISO timestamp without fractions of a second
- # (YYYY-MM-DDThh:mm:ss) is a valid value for colums which
+ # (YYYY-MM-DDThh:mm:ss) is a valid value for columns which
# store datetime values.
resultset = self.makeStormResultSet()
resultset.order_by(Person.datecreated)
range_factory = StormRangeFactory(resultset, self.logError)
valid_short_timestamp_json = '["2011-07-25T11:30:30"]'
self.assertEqual(
- [datetime(2011, 7, 25, 11, 30, 30)],
+ [datetime(2011, 7, 25, 11, 30, 30, tzinfo=pytz.UTC)],
range_factory.parseMemo(valid_short_timestamp_json))
self.assertEqual(0, len(self.error_messages))
def test_parseMemo__long_iso_timestamp(self):
# An ISO timestamp with fractions of a second
- # (YYYY-MM-DDThh:mm:ss.ffffff) is a valid value for colums
+ # (YYYY-MM-DDThh:mm:ss.ffffff) is a valid value for columns
# which store datetime values.
resultset = self.makeStormResultSet()
resultset.order_by(Person.datecreated)
range_factory = StormRangeFactory(resultset, self.logError)
valid_long_timestamp_json = '["2011-07-25T11:30:30.123456"]'
self.assertEqual(
- [datetime(2011, 7, 25, 11, 30, 30, 123456)],
+ [datetime(2011, 7, 25, 11, 30, 30, 123456, tzinfo=pytz.UTC)],
+ range_factory.parseMemo(valid_long_timestamp_json))
+ self.assertEqual(0, len(self.error_messages))
+
+ def test_parseMemo__short_iso_timestamp_with_tzoffset(self):
+ # An ISO timestamp with fractions of a second and a time zone
+ # offset (YYYY-MM-DDThh:mm:ss.ffffff+hh:mm) is a valid value
+ # for columns which store datetime values.
+ resultset = self.makeStormResultSet()
+ resultset.order_by(Person.datecreated)
+ range_factory = StormRangeFactory(resultset, self.logError)
+ valid_long_timestamp_json = '["2011-07-25T11:30:30-01:00"]'
+ self.assertEqual(
+ [datetime(2011, 7, 25, 12, 30, 30, tzinfo=pytz.UTC)],
+ range_factory.parseMemo(valid_long_timestamp_json))
+ self.assertEqual(0, len(self.error_messages))
+
+ def test_parseMemo__long_iso_timestamp_with_tzoffset(self):
+ # An ISO timestamp with fractions of a second and a time zone
+ # offset (YYYY-MM-DDThh:mm:ss.ffffff+hh:mm) is a valid value
+ # for columns which store datetime values.
+ resultset = self.makeStormResultSet()
+ resultset.order_by(Person.datecreated)
+ range_factory = StormRangeFactory(resultset, self.logError)
+ valid_long_timestamp_json = '["2011-07-25T11:30:30.123456+01:00"]'
+ self.assertEqual(
+ [datetime(2011, 7, 25, 10, 30, 30, 123456, tzinfo=pytz.UTC)],
range_factory.parseMemo(valid_long_timestamp_json))
self.assertEqual(0, len(self.error_messages))
@@ -320,7 +348,7 @@
["Invalid datetime value: '2011-05-35T11:30:30'"],
self.error_messages)
- def test_parseMemo__nonsencial_iso_timestamp_value(self):
+ def test_parseMemo__nonsensical_iso_timestamp_value(self):
# A memo string is rejected when an ISO timespamp is expected
# but a nonsensical string is provided.
resultset = self.makeStormResultSet()
@@ -333,6 +361,66 @@
["Invalid datetime value: 'bar'"],
self.error_messages)
+ def test_timestamp_regex__without_second_fraction_without_tzinfo(self):
+ # An ISO time string without fractions of a second and without
+ # time zone data is accepted by timestamp_regex.
+ mo = timestamp_regex.search('2011-01-02T12:03:04')
+ self.assertEqual('2011', mo.group('year'))
+ self.assertEqual('01', mo.group('month'))
+ self.assertEqual('02', mo.group('day'))
+ self.assertEqual('12', mo.group('hour'))
+ self.assertEqual('03', mo.group('minute'))
+ self.assertEqual('04', mo.group('second'))
+ self.assertIs(None, mo.group('sec_fraction'))
+ self.assertIs(None, mo.group('tzsign'))
+ self.assertIs(None, mo.group('tzhour'))
+ self.assertIs(None, mo.group('tzminute'))
+
+ def test_timestamp_regex__with_second_fraction_without_tzinfo(self):
+ # An ISO time string without fractions of a second and without
+ # time zone data is accepted by timestamp_regex.
+ mo = timestamp_regex.search('2011-01-02T12:03:04.123456')
+ self.assertEqual('2011', mo.group('year'))
+ self.assertEqual('01', mo.group('month'))
+ self.assertEqual('02', mo.group('day'))
+ self.assertEqual('12', mo.group('hour'))
+ self.assertEqual('03', mo.group('minute'))
+ self.assertEqual('04', mo.group('second'))
+ self.assertEqual('123456', mo.group('sec_fraction'))
+ self.assertIs(None, mo.group('tzsign'))
+ self.assertIs(None, mo.group('tzhour'))
+ self.assertIs(None, mo.group('tzminute'))
+
+ def test_timestamp_regex__without_second_fraction_with_tzinfo(self):
+ # An ISO time string without fractions of a second and without
+ # time zone data is accepted by timestamp_regex.
+ mo = timestamp_regex.search('2011-01-02T12:03:04+05:00')
+ self.assertEqual('2011', mo.group('year'))
+ self.assertEqual('01', mo.group('month'))
+ self.assertEqual('02', mo.group('day'))
+ self.assertEqual('12', mo.group('hour'))
+ self.assertEqual('03', mo.group('minute'))
+ self.assertEqual('04', mo.group('second'))
+ self.assertIs(None, mo.group('sec_fraction'))
+ self.assertEqual('+', mo.group('tzsign'))
+ self.assertEqual('05', mo.group('tzhour'))
+ self.assertEqual('00', mo.group('tzminute'))
+
+ def test_timestamp_regex__with_second_fraction_with_tzinfo(self):
+ # An ISO time string without fractions of a second and without
+ # time zone data is accepted by timestamp_regex.
+ mo = timestamp_regex.search('2011-01-02T12:03:04.123456-06:07')
+ self.assertEqual('2011', mo.group('year'))
+ self.assertEqual('01', mo.group('month'))
+ self.assertEqual('02', mo.group('day'))
+ self.assertEqual('12', mo.group('hour'))
+ self.assertEqual('03', mo.group('minute'))
+ self.assertEqual('04', mo.group('second'))
+ self.assertEqual('123456', mo.group('sec_fraction'))
+ self.assertEqual('-', mo.group('tzsign'))
+ self.assertEqual('06', mo.group('tzhour'))
+ self.assertEqual('07', mo.group('tzminute'))
+
def test_parseMemo__descending_sort_order(self):
# Validation of a memo string against a descending sort order works.
resultset = self.makeStormResultSet()
@@ -491,7 +579,7 @@
all_results = list(resultset)
range_factory = StormRangeFactory(resultset)
sliced_result = range_factory.getSlice(3)
- self.assertEqual(all_results[:3], list(sliced_result))
+ self.assertEqual(all_results[:3], sliced_result)
def test_getSlice__forward_with_memo(self):
resultset = self.makeStormResultSet()
@@ -500,7 +588,7 @@
memo = simplejson.dumps([all_results[0].name, all_results[0].id])
range_factory = StormRangeFactory(resultset)
sliced_result = range_factory.getSlice(3, memo)
- self.assertEqual(all_results[1:4], list(sliced_result))
+ self.assertEqual(all_results[1:4], sliced_result)
def test_getSlice__backward_without_memo(self):
resultset = self.makeStormResultSet()
@@ -510,7 +598,7 @@
expected.reverse()
range_factory = StormRangeFactory(resultset)
sliced_result = range_factory.getSlice(3, forwards=False)
- self.assertEqual(expected, list(sliced_result))
+ self.assertEqual(expected, sliced_result)
def test_getSlice_backward_with_memo(self):
resultset = self.makeStormResultSet()
@@ -521,7 +609,7 @@
memo = simplejson.dumps([all_results[4].name, all_results[4].id])
range_factory = StormRangeFactory(resultset)
sliced_result = range_factory.getSlice(3, memo, forwards=False)
- self.assertEqual(expected, list(sliced_result))
+ self.assertEqual(expected, sliced_result)
def makeResultSetWithPartiallyIdenticalSortData(self):
# Create a result set, where each value of
@@ -558,7 +646,7 @@
[memo_lfa.mimetype, memo_lfa.filename, memo_lfa.id])
range_factory = StormRangeFactory(resultset)
sliced_result = range_factory.getSlice(3, memo)
- self.assertEqual(all_results[3:6], list(sliced_result))
+ self.assertEqual(all_results[3:6], sliced_result)
def test_getSlice__decorated_resultset(self):
resultset = self.makeDecoratedStormResultSet()
@@ -567,4 +655,24 @@
memo = simplejson.dumps([resultset.get_plain_result_set()[0][1].id])
range_factory = StormRangeFactory(resultset)
sliced_result = range_factory.getSlice(3, memo)
+<<<<<<< TREE
self.assertEqual(all_results[1:4], list(sliced_result))
+=======
+ self.assertEqual(all_results[1:4], sliced_result)
+
+ def test_getSlice__returns_list(self):
+ # getSlice() returns lists.
+ resultset = self.makeStormResultSet()
+ resultset.order_by(Person.id)
+ all_results = list(resultset)
+ range_factory = StormRangeFactory(resultset)
+ sliced_result = range_factory.getSlice(3)
+ self.assertIsInstance(sliced_result, list)
+ memo = simplejson.dumps([all_results[0].name])
+ sliced_result = range_factory.getSlice(3, memo)
+ self.assertIsInstance(sliced_result, list)
+
+
+def test_suite():
+ return TestLoader().loadTestsFromName(__name__)
+>>>>>>> MERGE-SOURCE
=== modified file 'lib/canonical/launchpad/webapp/tests/test_errorlog.py'
=== modified file 'lib/lp/app/javascript/formwidgets/formwidgets.js'
--- lib/lp/app/javascript/formwidgets/formwidgets.js 2011-08-10 10:23:57 +0000
+++ lib/lp/app/javascript/formwidgets/formwidgets.js 2011-08-15 11:58:31 +0000
@@ -334,11 +334,16 @@
.setAttribute(
"for", item.one("input").generateID())
.setStyle("font-weight", "normal")
+<<<<<<< TREE
.set("text", choice.text);
item.setData(choice.data)
.setStyle("overflow", "hidden")
.setStyle("text-overflow", "ellipsis")
.setStyle("white-space", "nowrap");
+=======
+ .set("text", choice.text);
+ item.setData(choice.data);
+>>>>>>> MERGE-SOURCE
return item;
},
@@ -365,6 +370,7 @@
// Create a mapping of value -> choice for the given choices.
this._convertChoices(choices).forEach(
function(choice) {
+<<<<<<< TREE
choicemap[choice.value] = true;
}
);
@@ -379,6 +385,22 @@
},
/**
+=======
+ choicemap[choice.value] = true;
+ }
+ );
+ // Filter out the choices mentioned.
+ choices = this.get("choices").filter(function(choice) {
+ return !owns(choicemap, choice.value);
+ });
+ // Set back again.
+ this.set("choices", choices);
+ // Tell everyone!
+ Y.lazr.anim.green_flash({node: this.fieldNode}).run();
+ },
+
+ /**
+>>>>>>> MERGE-SOURCE
* Add new choices (if they are not already present).
*
* @method add_choices
@@ -391,6 +413,7 @@
choicemap[choice.value] = choice;
}
);
+<<<<<<< TREE
// Allow existing choices to be redefined.
choices = this.get("choices").map(function(choice) {
if (owns(choicemap, choice.value)) {
@@ -405,6 +428,22 @@
this.set("choices", choices);
// Tell everyone!
Y.lp.anim.green_flash({node: this.fieldNode}).run();
+=======
+ // Allow existing choices to be redefined.
+ choices = this.get("choices").map(function(choice) {
+ if (owns(choicemap, choice.value)) {
+ choice = choicemap[choice.value];
+ delete choicemap[choice.value];
+ }
+ return choice;
+ });
+ // Add the new choices (i.e. what remains in choicemap).
+ choices = choices.concat(values(choicemap));
+ // Set back again.
+ this.set("choices", choices);
+ // Tell everyone!
+ Y.lazr.anim.green_flash({node: this.fieldNode}).run();
+>>>>>>> MERGE-SOURCE
}
});
=== modified file 'lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js'
--- lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js 2011-08-09 16:31:48 +0000
+++ lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js 2011-08-15 11:58:31 +0000
@@ -498,6 +498,209 @@
testFormRowWidget, testChoiceListWidget);
suite.add(new Y.Test.Case(testChoiceListWidget));
+<<<<<<< TREE
+=======
+ var testSelectWidget = {
+ name: 'TestSelectWidget',
+
+ choices: [
+ {value: "a", text: "A", data: 123},
+ {value: "b", text: "B", data: 456},
+ {value: "c", text: "C", data: 789}
+ ],
+
+ setUp: function() {
+ this.container = Y.Node.create("<div />");
+ this.widget = new widgets.SelectWidget();
+ },
+
+ tearDown: function() {
+ this.container.remove(true);
+ },
+
+ testNameChange: function() {
+ this.widget
+ .set("name", "foo")
+ .set("choices", this.choices);
+ var select = this.widget.fieldNode.one("select");
+ Assert.areSame("foo", select.get("name"));
+ this.widget
+ .set("name", "bar");
+ Assert.areSame("bar", select.get("name"));
+ },
+
+ testChoices: function() {
+ this.widget.set("choices", this.choices);
+ var choices_observed = this.widget.get("choices");
+ /* We have to compare bit by bit ourselves because
+ Javascript is a language born in hell. */
+ ArrayAssert.itemsAreSame(
+ attrselect("value")(this.choices),
+ attrselect("value")(choices_observed));
+ ArrayAssert.itemsAreSame(
+ attrselect("text")(this.choices),
+ attrselect("text")(choices_observed));
+ ArrayAssert.itemsAreSame(
+ attrselect("data")(this.choices),
+ attrselect("data")(choices_observed));
+ },
+
+ testRenderChoices: function() {
+ this.widget.set("choices", this.choices);
+ this.widget.render(this.container);
+ ArrayAssert.itemsAreSame(
+ ["a", "b", "c"],
+ this.container.all("select > option").get("value"));
+ ArrayAssert.itemsAreSame(
+ ["A", "B", "C"],
+ this.container.all("select > option").get("text"));
+ },
+
+ testRenderEmptyChoices: function() {
+ this.widget.fieldNode.append("something");
+ this.widget.set("choices", []);
+ this.widget.render(this.container);
+ Assert.isNull(this.container.one("select"));
+ Assert.isFalse(this.widget.fieldNode.hasChildNodes());
+ },
+
+ testRenderChoicesChange: function() {
+ var choices1 = [
+ {value: "a", text: "A", data: 123}
+ ];
+ this.widget.set("choices", choices1);
+ this.widget.render(this.container);
+ var choices2 = [
+ {value: "b", text: "B", data: 456},
+ {value: "c", text: "C", data: 789}
+ ];
+ this.widget.set("choices", choices2);
+ ArrayAssert.itemsAreSame(
+ ["b", "c"],
+ this.container.all("select > option").get("value"));
+ ArrayAssert.itemsAreSame(
+ ["B", "C"],
+ this.container.all("select > option").get("text"));
+ },
+
+ testChoice: function() {
+ this.widget
+ .set("choices", this.choices)
+ .set("multiple", true);
+ /* It would be better to deselect all options by default,
+ but this appears impossible; the browser seems to
+ select the first option when rendering. */
+ this.widget.fieldNode.all("option").set("selected", false);
+ ArrayAssert.itemsAreSame(
+ [], this.widget.get("choice"));
+ this.widget.fieldNode.one("option[value=a]")
+ .set("selected", true);
+ ArrayAssert.itemsAreSame(
+ ["a"], this.widget.get("choice"));
+ this.widget.fieldNode.one("option[value=c]")
+ .set("selected", true);
+ ArrayAssert.itemsAreSame(
+ ["a", "c"], this.widget.get("choice"));
+ },
+
+ testSetChoice: function() {
+ this.widget
+ .set("multiple", true)
+ .set("choices", this.choices)
+ .set("choice", "a");
+ ArrayAssert.itemsAreSame(
+ ["a"], this.widget.get("choice"));
+ this.widget.set("choice", ["a"]);
+ ArrayAssert.itemsAreSame(
+ ["a"], this.widget.get("choice"));
+ this.widget.set("choice", ["a", "b"]);
+ ArrayAssert.itemsAreSame(
+ ["a", "b"], this.widget.get("choice"));
+ this.widget.set("choice", ["b", "z"]);
+ ArrayAssert.itemsAreSame(
+ ["b"], this.widget.get("choice"));
+ },
+
+ testSize: function() {
+ Assert.areSame(1, this.widget.get("size"));
+ },
+
+ testRenderSize: function() {
+ this.widget
+ .set("choices", this.choices)
+ .set("size", 7)
+ .render(this.container);
+ Assert.areSame(
+ 7, this.widget.fieldNode.one("select").get("size"));
+ },
+
+ testRenderSizeChange: function() {
+ this.widget
+ .set("choices", this.choices)
+ .set("size", 3)
+ .render(this.container)
+ .set("size", 5);
+ Assert.areSame(
+ 5, this.widget.fieldNode.one("select").get("size"));
+ },
+
+ testAutoSize: function() {
+ this.widget.set("choices", this.choices);
+ /* Without argument, autoSize() sets the size to the same
+ as the number of choices. */
+ this.widget.autoSize();
+ Assert.areSame(3, this.widget.get("size"));
+ },
+
+ testAutoSizeMoreChoicesThanMaxiumum: function() {
+ this.widget.set("choices", this.choices);
+ /* autoSize() sets the size to the same as the number of
+ choices unless there are more than the specified
+ maximum. */
+ this.widget.autoSize(2);
+ Assert.areSame(2, this.widget.get("size"));
+ },
+
+ testAutoSizeFewerChoicesThanMaxiumum: function() {
+ this.widget.set("choices", this.choices);
+ /* autoSize() sets the size to the same as the number of
+ choices. */
+ this.widget.autoSize(5);
+ Assert.areSame(3, this.widget.get("size"));
+ },
+
+ testMultiple: function() {
+ Assert.areSame(false, this.widget.get("multiple"));
+ },
+
+ testRenderMultiple: function() {
+ this.widget
+ .set("choices", this.choices)
+ .set("multiple", true)
+ .render(this.container);
+ Assert.isTrue(
+ this.widget.fieldNode.one("select")
+ .hasAttribute("multiple"));
+ },
+
+ testRenderMultipleChange: function() {
+ this.widget
+ .set("choices", this.choices)
+ .set("multiple", true)
+ .render(this.container)
+ .set("multiple", false);
+ Assert.isFalse(
+ this.widget.fieldNode.one("select")
+ .hasAttribute("multiple"));
+ }
+
+ };
+
+ testSelectWidget = Y.merge(
+ testFormRowWidget, testSelectWidget);
+ suite.add(new Y.Test.Case(testSelectWidget));
+
+>>>>>>> MERGE-SOURCE
var testFormActionsWidget = {
name: 'TestFormActionsWidget',
=== modified file 'lib/lp/registry/javascript/distroseries/tests/test_widgets.js'
=== modified file 'lib/lp/registry/javascript/distroseries/widgets.js'
--- lib/lp/registry/javascript/distroseries/widgets.js 2011-08-09 16:19:19 +0000
+++ lib/lp/registry/javascript/distroseries/widgets.js 2011-08-15 11:58:31 +0000
@@ -483,7 +483,37 @@
var content = "<div>[No package sets to select from yet!]</div>";
this.fieldNode.empty().append(content);
}
- },
+<<<<<<< TREE
+ },
+=======
+ }
+});
+
+
+Y.extend(PackagesetPickerWidget, formwidgets.ChoiceListWidget, {
+
+ /**
+ * Return whether this widget is displaying any choices.
+ *
+ * @method _hasChoices
+ * @return {Boolean}
+ */
+ _hasChoices: function() {
+ return this.fieldNode.one("li > input") !== null;
+ },
+
+ /**
+ * Display a message when no parent series are selected.
+ *
+ * @method _cleanDisplay
+ */
+ _cleanDisplay: function() {
+ if (!this._hasChoices()) {
+ var content = "<div>[No package sets to select from yet!]</div>";
+ this.fieldNode.empty().append(content);
+ }
+ },
+>>>>>>> MERGE-SOURCE
/**
* Add a distroseries: add its packagesets to the packageset picker.
@@ -564,6 +594,7 @@
this.error_handler = new Y.lp.client.ErrorHandler();
this.error_handler.clearProgressUI = Y.bind(this.hideSpinner, this);
this.error_handler.showError = Y.bind(this.showError, this);
+<<<<<<< TREE
/* Display a message if there are no choices. */
this.after("choicesChange", Y.bind(this._cleanDisplay, this));
this._cleanDisplay();
@@ -576,6 +607,20 @@
distroseries is added as a parent and used when a
distroseries is removed to get all the corresponding
packagesets to be removed from the widget. */
+=======
+ /* Display a message if there are no choices. */
+ this.after("choicesChange", Y.bind(this._cleanDisplay, this));
+ this._cleanDisplay();
+ /* Green-flash when the set of choices is changed. */
+ this.after("choicesChange", function(e) {
+ Y.lazr.anim.green_flash({node: this.fieldNode}).run();
+ });
+ /* _packagesets maps each distroseries' id to a collection of
+ ids of its packagesets. It's populated each time a new
+ distroseries is added as a parent and used when a
+ distroseries is removed to get all the corresponding
+ packagesets to be removed from the widget. */
+>>>>>>> MERGE-SOURCE
this._packagesets = {};
}
@@ -586,5 +631,10 @@
}, "0.1", {"requires": [
"node", "dom", "io", "widget", "lp.client",
+<<<<<<< TREE
"lp.app.formwidgets", "lp.anim", "array-extras",
"substitute", "transition"]});
+=======
+ "lp.app.formwidgets", "lazr.anim", "array-extras",
+ "substitute", "transition"]});
+>>>>>>> MERGE-SOURCE
=== modified file 'lib/lp/registry/javascript/distroseriesdifferences_details.js'
--- lib/lp/registry/javascript/distroseriesdifferences_details.js 2011-08-11 13:58:54 +0000
+++ lib/lp/registry/javascript/distroseriesdifferences_details.js 2011-08-15 11:58:31 +0000
@@ -14,6 +14,7 @@
/**
* Create one Launchpad client that will be used with multiple requests.
*/
+<<<<<<< TREE
namespace.lp_client = new Y.lp.client.Launchpad();
namespace.io = Y.io;
@@ -48,7 +49,31 @@
},
expander_handler: function(e) {
+=======
+var lp_client = new Y.lp.client.Launchpad();
+
+/*
+ * XXX: rvb 2011-08-01 bug=796669: At present this module it is
+ * function-passing spaghetti. The duct-tape is getting frayed.
+ * It ought to be recomposed as widgets or something a bit more objecty so
+ * it can be unit tested without having to set-up the world each time.
+ */
+
+/*
+ * Setup the expandable rows for each difference.
+ *
+ * @method setup_expandable_rows
+ */
+namespace.setup_expandable_rows = function() {
+
+ var blacklist_handler = function(e, dsd_link, source_name,
+ latest_comment_container,
+ add_comment_placeholder) {
+ // We only want to select the new radio if the update is
+ // successful.
+>>>>>>> MERGE-SOURCE
e.preventDefault();
+<<<<<<< TREE
var parsed = this.parse_row_data();
this._toggle.toggleClass('treeCollapsed').toggleClass('treeExpanded');
@@ -119,6 +144,322 @@
// Set-up diffs and the means to request them.
namespace.setup_packages_diff_states(container, api_uri);
},
+=======
+ var blacklist_options_container = this.ancestor('div');
+ blacklist_comment_overlay(
+ e, dsd_link, source_name, latest_comment_container,
+ add_comment_placeholder, blacklist_options_container);
+ };
+
+ var blacklist_comment_overlay = function(e, dsd_link, source_name,
+ latest_comment_container,
+ add_comment_placeholder,
+ blacklist_options_container) {
+ var comment_form = Y.Node.create("<form />")
+ .appendChild(Y.Node.create("<textarea />")
+ .set("name", "comment")
+ .set("rows", "3")
+ .set("cols", "60"));
+
+ /* Buttons */
+ var submit_button = Y.Node.create(
+ '<button type="submit" class="lazr-pos lazr-btn" />')
+ .set("text", "OK");
+ var cancel_button = Y.Node.create(
+ '<button type="button" class="lazr-neg lazr-btn" />')
+ .set("text", "Cancel");
+
+ var submit_callback = function(data) {
+ overlay.hide();
+ var comment = "";
+ if (data.comment !== undefined) {
+ comment = data.comment[0];
+ }
+ blacklist_submit_handler(
+ e, dsd_link, source_name, comment, latest_comment_container,
+ add_comment_placeholder, blacklist_options_container);
+
+ };
+ var origin = blacklist_options_container.one('.blacklist-options');
+ var overlay = new Y.lazr.FormOverlay({
+ align: {
+ /* Align the centre of the overlay with the centre of the
+ node containing the blacklist options. */
+ node: origin,
+ points: [
+ Y.WidgetPositionAlign.CC,
+ Y.WidgetPositionAlign.CC
+ ]
+ },
+ headerContent: "<h2>Add an optional comment</h2>",
+ form_content: comment_form,
+ form_submit_button: submit_button,
+ form_cancel_button: cancel_button,
+ form_submit_callback: submit_callback,
+ visible: true
+ });
+ overlay.render();
+ };
+
+ var blacklist_submit_handler = function(e, dsd_link, source_name,
+ comment, latest_comment_container,
+ add_comment_placeholder,
+ blacklist_options_container) {
+ // Disable all the inputs.
+ blacklist_options_container.all('input').set('disabled', 'disabled');
+ e.target.prepend('<img src="/@@/spinner" />');
+
+ var method_name = (e.target.get('value') === 'NONE') ?
+ 'unblacklist' : 'blacklist';
+ var blacklist_all = (e.target.get('value') === 'BLACKLISTED_ALWAYS');
+
+ var diff_rows = Y.all('tr.' + source_name);
+
+ var config = {
+ on: {
+ success: function(updated_entry, args) {
+ // Let the user know this item is now blacklisted.
+ blacklist_options_container.one('img').remove();
+ blacklist_options_container.all(
+ 'input').set('disabled', false);
+ e.target.set('checked', true);
+ Y.each(diff_rows, function(diff_row) {
+ var fade_to_gray = new Y.Anim({
+ node: diff_row,
+ from: { backgroundColor: '#FFFFFF'},
+ to: { backgroundColor: '#EEEEEE'}
+ });
+ if (method_name === 'unblacklist') {
+ fade_to_gray.set('reverse', true);
+ }
+ fade_to_gray.run();
+ });
+ add_comment(
+ updated_entry, add_comment_placeholder,
+ latest_comment_container);
+ },
+ failure: function(id, response) {
+ blacklist_options_container.one('img').remove();
+ blacklist_options_container.all(
+ 'input').set('disabled', false);
+ }
+ },
+ parameters: {
+ all: blacklist_all,
+ comment: comment
+ }
+ };
+
+ lp_client.named_post(dsd_link, method_name, config);
+
+ };
+
+ /**
+ * Link the click event for these blacklist options to the correct
+ * api uri.
+ *
+ * @param blacklist_options {Node} The node containing the blacklist
+ * options.
+ * @param source_name {string} The name of the source to update.
+ * @param dsd_link {string} The uri for the distroseriesdifference object.
+ * @param latest_comment_container {Node} The node containing the last
+ * comment.
+ * @param add_comment_placeholder {Node} The node containing the "add
+ * comment" slot.
+ */
+ var setup_blacklist_options = function(
+ blacklist_options, source_name, dsd_link, latest_comment_container,
+ add_comment_placeholder) {
+ Y.on('click', blacklist_handler, blacklist_options.all('input'),
+ blacklist_options, dsd_link, source_name,
+ latest_comment_container, add_comment_placeholder);
+ };
+
+ /**
+ * Update the latest comment on the difference row.
+ *
+ * @param comment_entry {lp.client.Entry} An object representing
+ * a DistroSeriesDifferenceComment.
+ * @param placeholder {Node}
+ * The node in which to put the latest comment HTML fragment. The
+ * contents of this node will be replaced.
+ */
+ var update_latest_comment = function(comment_entry, placeholder) {
+ var comment_latest_fragment_url =
+ comment_entry.get("web_link") + "/+latest-comment-fragment";
+ var config = {
+ on: {
+ success: function(comment_latest_fragment_html) {
+ placeholder.set(
+ "innerHTML", comment_latest_fragment_html);
+ Y.lazr.anim.green_flash({node: placeholder}).run();
+ },
+ failure: function(id, response) {
+ Y.lazr.anim.red_flash({node: placeholder}).run();
+ }
+ },
+ accept: Y.lp.client.XHTML
+ };
+ lp_client.get(comment_latest_fragment_url, config);
+ };
+
+ /**
+ * This method adds a comment in the UI. It appends a comment to the
+ * list of comments and updates the latest comments slot.
+ *
+ * @param comment_entry {Comment} A comment as returns by the api.
+ * @param add_comment_placeholder {Node} The node that contains the
+ * relevant comment fields.
+ * @param latest_comment_placeholder {Node} The node that contains the
+ * latest comment.
+ */
+ var add_comment = function(comment_entry, add_comment_placeholder,
+ latest_comment_placeholder) {
+ // Grab the XHTML representation of the comment
+ // and prepend it to the list of comments.
+ var config = {
+ on: {
+ success: function(comment_html) {
+ var comment_node = Y.Node.create(comment_html);
+ add_comment_placeholder.insert(comment_node, 'before');
+ var reveal = Y.lazr.effects.slide_out(comment_node);
+ reveal.on("end", function() {
+ Y.lazr.anim.green_flash(
+ {node: comment_node}).run();
+ });
+ reveal.run();
+ }
+ },
+ accept: Y.lp.client.XHTML
+ };
+ lp_client.get(comment_entry.get('self_link'), config);
+ update_latest_comment(comment_entry, latest_comment_placeholder);
+ };
+
+ /**
+ * Handle the add comment event triggered by the 'add comment' form.
+ *
+ * This method adds a comment via the API and update the UI.
+ *
+ * @param comment_form {Node} The node that contains the relevant comment
+ * fields.
+ * @param latest_comment_placeholder {Node} The node that contains the
+ * latest comment.
+ * @param api_uri {string} The uri for the distroseriesdifference to which
+ * the comment is to be added.
+ * @param cb_success {function} Called when a comment has successfully
+ * been added. (Deferreds would be awesome right about now.)
+ */
+ var add_comment_handler = function(
+ comment_form, latest_comment_placeholder, api_uri, cb_success) {
+
+ var comment_area = comment_form.one('textarea');
+ var comment_text = comment_area.get('value');
+
+ // Treat empty comments as mistakes.
+ if (Y.Lang.trim(comment_text).length === 0) {
+ Y.lazr.anim.red_flash({node: comment_area}).run();
+ return;
+ }
+
+ var success_handler = function(comment_entry) {
+ add_comment(
+ comment_entry, comment_form, latest_comment_placeholder);
+ comment_form.one('textarea').set('value', '');
+ cb_success();
+ };
+ var failure_handler = function(id, response) {
+ // Re-enable field with red flash.
+ Y.lazr.anim.red_flash({node: comment_form}).run();
+ };
+
+ var config = {
+ on: {
+ success: success_handler,
+ failure: failure_handler,
+ start: function() {
+ // Show a spinner.
+ comment_form.one('div.widget-bd')
+ .append('<img src="/@@/spinner" />');
+ // Disable the textarea and button.
+ comment_form.all('textarea,button')
+ .setAttribute('disabled', 'disabled');
+ },
+ end: function() {
+ // Remove the spinner.
+ comment_form.all('img').remove();
+ // Enable the form.
+ comment_form.all('textarea,button')
+ .removeAttribute('disabled');
+ }
+ },
+ parameters: {
+ comment: comment_text
+ }
+ };
+ lp_client.named_post(api_uri, 'addComment', config);
+ };
+
+ /**
+ * Add the comment fields ready for sliding out.
+ *
+ * This method adds the markup for a slide-out comment and sets
+ * the event handlers.
+ *
+ * @param placeholder {Node} The node that is to contain the comment
+ * fields.
+ * @param api_uri {string} The uri for the distroseriesdifference to which
+ * the comment is to be added.
+ */
+ var setup_add_comment = function(
+ placeholder, latest_comment_placeholder, api_uri) {
+ placeholder.insert([
+ '<a class="widget-hd js-action sprite add" href="">',
+ ' Add comment</a>',
+ '<div class="widget-bd lazr-closed" ',
+ ' style="height:0;overflow:hidden">',
+ ' <textarea></textarea><button>Save comment</button>',
+ '</div>'
+ ].join(''), 'replace');
+
+ // The comment area should slide in when the 'Add comment'
+ // action is clicked.
+ var slide_anim = null;
+ var slide = function(direction) {
+ // Slide out if direction is true, slide in if direction
+ // is false, otherwise do the opposite of what's being
+ // animated right now.
+ if (slide_anim === null) {
+ slide_anim = Y.lazr.effects.slide_out(
+ placeholder.one('div.widget-bd'));
+ if (Y.Lang.isBoolean(direction)) {
+ slide_anim.set("reverse", !direction);
+ }
+ }
+ else {
+ if (Y.Lang.isBoolean(direction)) {
+ slide_anim.set("reverse", !direction);
+ }
+ else {
+ slide_anim.set('reverse', !slide_anim.get('reverse'));
+ }
+ slide_anim.stop();
+ }
+ slide_anim.run();
+ };
+ var slide_in = function() { slide(false); };
+
+ placeholder.one('a.widget-hd').on(
+ 'click', function(e) { e.preventDefault(); slide(); });
+
+ placeholder.one('button').on('click', function(e) {
+ e.preventDefault();
+ add_comment_handler(
+ placeholder, latest_comment_placeholder,
+ api_uri, slide_in);
+ });
+ };
+>>>>>>> MERGE-SOURCE
/**
* Get the extra information for this diff to display.
@@ -146,9 +487,53 @@
container.one('div.diff-extra-container').insert(
in_progress_message, 'replace');
var success_cb = function(transaction_id, response, args) {
+<<<<<<< TREE
this.setup_extra_diff_info(
master_container, container, source_name, parent_distro_name,
parent_series_name, response);
+=======
+ args.container.one('div.diff-extra-container').insert(
+ response.responseText, 'replace');
+ var api_uri = [
+ LP.cache.context.self_link,
+ '+source',
+ source_name,
+ '+difference',
+ parent_distro_name,
+ parent_series_name
+ ].join('/');
+ var latest_comment_container =
+ args.master_container.one('td.latest-comment-fragment');
+ // The add comment slot is only available when the user has the
+ // right to add comments.
+ var add_comment_placeholder =
+ args.container.one('div.add-comment-placeholder');
+ if (add_comment_placeholder !== null) {
+ setup_add_comment(
+ add_comment_placeholder,
+ latest_comment_container,
+ api_uri);
+ }
+ // The blacklist slot with a class 'blacklist-options' is only
+ // available when the user has the right to blacklist.
+ var blacklist_slot = args.container.one('div.blacklist-options');
+ if (blacklist_slot !== null) {
+ setup_blacklist_options(
+ blacklist_slot, source_name, api_uri,
+ latest_comment_container,
+ add_comment_placeholder);
+ }
+ // If the user has not the right to blacklist, we disable
+ // the blacklist slot.
+ var disabled_blacklist_slot = args.container.one(
+ 'div.blacklist-options-disabled');
+ if (disabled_blacklist_slot !== null) {
+ disabled_blacklist_slot
+ .all('input').set('disabled', 'disabled');
+ }
+ // Set-up diffs and the means to request them.
+ namespace.setup_packages_diff_states(args.container, api_uri);
+>>>>>>> MERGE-SOURCE
};
var failure_cb = function(transaction_id, response, args) {
=== modified file 'lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.js'
=== modified file 'lib/lp/soyuz/scripts/tests/test_initialize_distroseries.py'
=== modified file 'versions.cfg'
--- versions.cfg 2011-08-15 02:51:13 +0000
+++ versions.cfg 2011-08-15 11:58:31 +0000
@@ -49,7 +49,11 @@
mocker = 0.10.1
mozrunner = 1.3.4
oauth = 1.0
+<<<<<<< TREE
oops = 0.0.3
+=======
+oops = 0.0.1
+>>>>>>> MERGE-SOURCE
paramiko = 1.7.4
Paste = 1.7.2
PasteDeploy = 1.3.3