Merge lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part2 into lp:launchpad

Proposed by Edwin Grubbs
Status: Merged
Approved by: Guilherme Salgado
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part2
Merge into: lp:launchpad
Prerequisite: lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part1
Diff against target: 962 lines (+399/-202)
16 files modified
lib/canonical/launchpad/icing/style-3-0.css (+3/-0)
lib/canonical/launchpad/icing/style.css (+0/-2)
lib/canonical/launchpad/javascript/code/codereview.js (+7/-6)
lib/canonical/launchpad/javascript/lp/errors.js (+33/-3)
lib/canonical/launchpad/javascript/registry/team.js (+83/-49)
lib/lp/registry/browser/person.py (+33/-21)
lib/lp/registry/browser/tests/mailinglist-views.txt (+5/-4)
lib/lp/registry/interfaces/person.py (+27/-17)
lib/lp/registry/model/person.py (+8/-5)
lib/lp/registry/model/teammembership.py (+2/-10)
lib/lp/registry/stories/team/xx-team-home.txt (+21/-10)
lib/lp/registry/stories/team/xx-team-membership.txt (+13/-0)
lib/lp/registry/templates/team-index.pt (+1/-1)
lib/lp/registry/templates/team-members.pt (+53/-51)
lib/lp/registry/templates/team-portlet-membership.pt (+36/-23)
lib/lp/registry/windmill/tests/test_team_index.py (+74/-0)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part2
Reviewer Review Type Date Requested Status
Martin Albisetti (community) ui Approve
Curtis Hovey (community) code and js Approve
Guilherme Salgado (community) Needs Fixing
Canonical Launchpad Engineering code ui Pending
Review via email: mp+16325@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Summary
-------

This branch is dependent on
lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part1

I made the following changes:
 * Added a windmill test
 * Changed the return value from IPerson.addMember() so
   the UI knows what status it decided to use.
 * If the user is allowed to invite a member because they can admin
   the owning team and if the user is allowed to accept the invitation
   because they can admin the member team, addMember() now just fully
   approves the membership.

Over 50 tests broke because I changed the return value for
addMember() and setStatus(). I only fixed one of them as a sample.
The rest will have to wait for a followup branch, since I'm interested
in getting feedback on the changes I made already.

Implementation details
----------------------

lib/canonical/launchpad/icing/style-3-0.css
lib/canonical/launchpad/icing/style.css
    Moved the unseen style to style-3-0.css since style.css
    will be going away eventually.

lib/canonical/launchpad/javascript/code/codereview.js
    Added bug number to XXX.

lib/canonical/launchpad/javascript/registry/team.js
    Added handling of teams as new members.

lib/lp/registry/browser/person.py
    Added code for a list of recently invited members in
    the membership portlet.
    Using a css class to hide the lists instead of setting
    the element's style attribute.
    Modified the view for +add-my-teams so that it matches
    the behavior of the ajax "Add member" link.

lib/lp/registry/browser/tests/mailinglist-views.txt
    Fixed test for new return values.

lib/lp/registry/interfaces/person.py
    Documented new return value and organized docs by param.

lib/lp/registry/model/person.py
    Changed return value.

lib/lp/registry/templates/team-index.pt
    Fixed mixing space between left and right portlets.

lib/lp/registry/templates/team-members.pt
    Hide former members list from non-admins, since some users
    complain when it appears that they used to be a member of a team
    that they were added to on accident.

lib/lp/registry/templates/team-portlet-membership.pt
    Added recently invited member list.

lib/lp/registry/windmill/tests/test_team_index.py
    Added windmill test.

Tests
-----

./bin/test -vv -t mailinglist-views.txt
./bin/test -vv --layer=RegistryWindmillTest -t test_team_index

Demo and Q/A
------------

* Open http://launchpad.dev/~testing-spanish-team
  * Log in as <email address hidden>.
  * Click on the green "Add member" link in the portlet.
  * Search for "rosetta" and click on the rosetta admins team.
  * That team should be added to the "Recently approved" list
    since carlos admins both of them.
* Click on the green "Add member" link in the portlet.
  * Search for "simple" and click on the Simple Team.
  * That team should be added to the "Recently invited" list
    since carlos does not admin the Simple Team.
* Log out.
  * The "Add member" link should be gone.

Revision history for this message
Guilherme Salgado (salgado) wrote :
Download full text (12.2 KiB)

Hi Edwin,

This is a nice branch but I think we're missing some test coverage for
some changes you introduced.

 review needsfixing

On Fri, 2009-12-18 at 06:36 +0000, Edwin Grubbs wrote:
> Summary
> -------
>
> This branch is dependent on
> lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part1
>
> I made the following changes:
> * Added a windmill test
> * Changed the return value from IPerson.addMember() so
> the UI knows what status it decided to use.
> * If the user is allowed to invite a member because they can admin
> the owning team and if the user is allowed to accept the invitation
> because they can admin the member team, addMember() now just fully
> approves the membership.
>
> Over 50 tests broke because I changed the return value for
> addMember() and setStatus(). I only fixed one of them as a sample.
> The rest will have to wait for a followup branch, since I'm interested
> in getting feedback on the changes I made already.

I think most of these tests are probably not interested in the return
value of addMember(), and in that case I suggest you change these ones
to assign the return value of addMember() to a dummy variable so that
they don't break again when we need to change the return value once
again.

> === modified file 'lib/canonical/launchpad/javascript/registry/team.js'
> --- lib/canonical/launchpad/javascript/registry/team.js 2009-12-18 06:36:27 +0000
> +++ lib/canonical/launchpad/javascript/registry/team.js 2009-12-18 06:36:29 +0000
> @@ -15,87 +15,122 @@
> * @method setup_add_member_handler
> */
> module.setup_add_member_handler = function() {
> + if (Y.UA.ie) {
> + return;
> + }
> +
> var config = {
> header: 'Add a member',
> step_title: 'Search',
> picker_activator: '.menu-link-add_member'
> };
>
> - var picker = Y.lp.picker.create(
> - 'ValidTeamMember',
> - function(result) { _add_member(result); },
> - config);
> + Y.lp.picker.create('ValidTeamMember', _add_member, config);
> };
>
> -var _add_member = function(result) {
> - var spinner = Y.one('#add-member-spinner');
> - var addmember_link = Y.one('.menu-link-add_member');
> - addmember_link.setStyle('display', 'none');
> - spinner.setStyle('display', 'inline');
> - function disable_spinner() {
> - addmember_link.setStyle('display', 'inline');
> - spinner.setStyle('display', 'none');
> - }
> +var _add_member = function(selected_person) {
> + var box = Y.one('#membership');
> + var spinner = box.query('#add-member-spinner');
> + var addmember_link = box.query('.menu-link-add_member');
> + addmember_link.addClass('unseen');
> + spinner.removeClass('unseen');
> + var disable_spinner = function() {
> + addmember_link.removeClass('unseen');
> + spinner.addClass('unseen');
> + };
> lp_client = new LP.client.Launchpad();
>
> var error_handler = new LP.client.ErrorHandler();
> error_handler.clearProgressUI = disable_spinner;
> error_handler.showError = function(error_msg) {
> - Y.lp.display_error(Y.one('.menu-link-add_member'), error_msg);
> + Y.lp.displ...

review: Needs Fixing
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Here are screenshots of this feature that show how adding a person or a team as a member updates the appropriate list and updates the counts at the top of the portlet. If the user adds a team that he does not admin, then the team is invited. If the user does admin the team being added, it skips the invitation status and goes straight to approved.

https://chinstrap.canonical.com/~egrubbs/addmember/

Revision history for this message
Martin Albisetti (beuno) wrote :

Hi Edwin,

The branch in general looks great, you solved the bulk of the challenges already.
As per our conversation on IRC, the actual fix for the "user is already approved" message is not getting in the situation in the first place, so filing a bug about is a good start.

If we're going to keep the dialog around for a while, I would do one of two things:

1) Add buttons to dismiss the dialog or go back to the results (OK and BACK?)
2) Show the message instead of the results (ie, keep the search fields visible to re-search)

I would also re-phrase the message to something like "$user is already part of the team"

review: Needs Fixing (ui)
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (13.1 KiB)

Hi Salgado,

Thanks for the review. Comments below.

> Hi Edwin,
>
> This is a nice branch but I think we're missing some test coverage for
> some changes you introduced.
>
> review needsfixing
>
> On Fri, 2009-12-18 at 06:36 +0000, Edwin Grubbs wrote:
> > Summary
> > -------
> >
> > This branch is dependent on
> > lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part1
> >
> > I made the following changes:
> > * Added a windmill test
> > * Changed the return value from IPerson.addMember() so
> > the UI knows what status it decided to use.
> > * If the user is allowed to invite a member because they can admin
> > the owning team and if the user is allowed to accept the invitation
> > because they can admin the member team, addMember() now just fully
> > approves the membership.
> >
> > Over 50 tests broke because I changed the return value for
> > addMember() and setStatus(). I only fixed one of them as a sample.
> > The rest will have to wait for a followup branch, since I'm interested
> > in getting feedback on the changes I made already.
>
> I think most of these tests are probably not interested in the return
> value of addMember(), and in that case I suggest you change these ones
> to assign the return value of addMember() to a dummy variable so that
> they don't break again when we need to change the return value once
> again.

I added the dummy variable in the mailinglist-views.txt test, and I will
do that for the others.

> > === modified file 'lib/canonical/launchpad/javascript/registry/team.js'
> > --- lib/canonical/launchpad/javascript/registry/team.js 2009-12-18 06:36:27
> +0000
> > +++ lib/canonical/launchpad/javascript/registry/team.js 2009-12-18 06:36:29
> +0000
> > @@ -15,87 +15,122 @@
> > * @method setup_add_member_handler
> > */
> > module.setup_add_member_handler = function() {
> > + if (Y.UA.ie) {
> > + return;
> > + }
> > +
> > var config = {
> > header: 'Add a member',
> > step_title: 'Search',
> > picker_activator: '.menu-link-add_member'
> > };
> >
> > - var picker = Y.lp.picker.create(
> > - 'ValidTeamMember',
> > - function(result) { _add_member(result); },
> > - config);
> > + Y.lp.picker.create('ValidTeamMember', _add_member, config);
> > };
> >
> > -var _add_member = function(result) {
> > - var spinner = Y.one('#add-member-spinner');
> > - var addmember_link = Y.one('.menu-link-add_member');
> > - addmember_link.setStyle('display', 'none');
> > - spinner.setStyle('display', 'inline');
> > - function disable_spinner() {
> > - addmember_link.setStyle('display', 'inline');
> > - spinner.setStyle('display', 'none');
> > - }
> > +var _add_member = function(selected_person) {
> > + var box = Y.one('#membership');
> > + var spinner = box.query('#add-member-spinner');
> > + var addmember_link = box.query('.menu-link-add_member');
> > + addmember_link.addClass('unseen');
> > + spinner.removeClass('unseen');
> > + var disable_spinner = function() {
> > + addmember_link.removeClass('unseen');
> > + spinner.addClass('unseen');
> > + };
> > ...

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (7.4 KiB)

Here is the incremental diff that addresses both Salgado's and Martin's comments.

{{{
=== modified file 'lib/canonical/launchpad/javascript/lp/errors.js'
--- lib/canonical/launchpad/javascript/lp/errors.js 2009-11-23 19:29:02 +0000
+++ lib/canonical/launchpad/javascript/lp/errors.js 2009-12-21 20:33:38 +0000
@@ -69,7 +69,7 @@
  * @param flash_node {Node} The node to red flash.
  * @param msg {String} The message to display.
 */
-var display_error = function(flash_node, msg) {
+Y.lp.display_error = function(flash_node, msg) {
     create_error_overlay();
     maybe_red_flash(flash_node, function(){
         error_overlay.showError(msg);
@@ -77,5 +77,36 @@
     });
 };

-Y.lp.display_error = display_error;
-}, "0.1", {"requires":["lazr.formoverlay"]});
+
+var info_overlay;
+/*
+ * Display the form overlay for non-error informational messages.
+ *
+ * @method display_info
+ * @param msg {String} The message to display.
+*/
+Y.lp.display_info = function(msg) {
+ if (info_overlay === undefined) {
+ info_overlay = new Y.lazr.PrettyOverlay({
+ //headerContent: '<h2><img src="/@@/info-large"/>Notice</h2>',
+ centered: true,
+ visible: false
+ });
+ info_overlay.render();
+ }
+ var content = Y.Node.create(
+ '<div style="background: url(/@@/info-large) no-repeat; ' +
+ 'min-height: 32px; padding-left: 40px; padding-top: 16px"/></div>');
+ content.appendChild(Y.Node.create(msg));
+ var button_div = Y.Node.create('<div style="text-align: right"></div>');
+ var ok_button = Y.Node.create('<button>OK</button>');
+ ok_button.on('click', function(e) {
+ info_overlay.fire('cancel');
+ });
+ button_div.appendChild(ok_button);
+ content.appendChild(button_div);
+ info_overlay.set('bodyContent', content);
+ info_overlay.show();
+};
+
+}, "0.1", {"requires":["lazr.formoverlay", "lazr.overlay"]});

=== modified file 'lib/canonical/launchpad/javascript/registry/team.js'
--- lib/canonical/launchpad/javascript/registry/team.js 2009-12-18 06:06:30 +0000
+++ lib/canonical/launchpad/javascript/registry/team.js 2009-12-21 20:35:57 +0000
@@ -54,13 +54,14 @@
                 var members_section, members_ul, count_elem;
                 if (did_status_change === false) {
                     disable_spinner();
- Y.lp.display_error(
- addmember_link,
+ Y.lp.display_info(
                         selected_person.title + ' is already ' +
- current_status + '.');
+ current_status.toLowerCase() +
+ ' as a member of the team.');
                     return;
                 }
                 var is_team = false;
+ current_status = 'foo';
                 if (current_status == 'Invited') {
                     is_team = true;
                     members_section = box.query('#recently-invited');
@@ -79,6 +80,7 @@
                     Y.lp.display_error(
                         addmember_link,
                         'Unexpected status: ' + current_status);
+ return;
                 }
                 var firs...

Read more...

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Hi Martin,

Thanks for the review. For some reason, your comment didn't get added to the merge proposal, so I pasted it here again.

On Mon, Dec 21, 2009 at 1:35 PM, Martin Albisetti <email address hidden> wrote:
> Review: Needs Fixing ui
> Hi Edwin,
>
> The branch in general looks great, you solved the bulk of the challenges already.
> As per our conversation on IRC, the actual fix for the "user is already approved" message is not getting in the situation in the first place, so filing a bug about is a good start.

Opened bug 499210 for this.

> If we're going to keep the dialog around for a while, I would do one of two things:
>
> 1) Add buttons to dismiss the dialog or go back to the results (OK and BACK?)
> 2) Show the message instead of the results (ie, keep the search fields visible to re-search)

I added an "OK" button to dismiss the dialog.

> I would also re-phrase the message to something like "$user is already part of the team"

There actually are two potential errors, since a team could already be in an invited status, so it would be unclear to say that they are already part of the team. Here are screenshots of both error messages.

https://chinstrap.canonical.com/~egrubbs/addmember/new_approved_error_message.png
https://chinstrap.canonical.com/~egrubbs/addmember/new_invited_error_message.png

Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Edwin.

This is a nice addition. I have no remarks about new test. I have a suggestion about the team story

> === modified file 'lib/lp/registry/stories/team/xx-team-home.txt'
> --- lib/lp/registry/stories/team/xx-team-home.txt 2009-11-22 15:43:16 +0000
> +++ lib/lp/registry/stories/team/xx-team-home.txt 2009-12-21 19:53:58 +0000

...

> @@ -88,14 +93,14 @@
> Show received invitations
>
> If the team does not have any recently approved or proposed members,
> -the recent members sections are not displayed:
> +the recent members sections are hidden using the "unseen" css class:
>
> >>> browser.open('http://launchpad.dev/~launchpad')
> >>> print find_tag_by_id(browser.contents, 'recently-approved')
> - None
> + <td id="recently-approved" ... class="unseen">...
>
> - >>> print find_tag_by_id(browser.contents, 'recently-applied')
> - None
> + >>> print find_tag_by_id(browser.contents, 'recently-proposed')
> + <td id="recently-proposed" class="unseen">...

These tests will break if we reimplement this as a list. We should not print
markup in user stories because they do no read the source. The test is parsing
the tree many times. I think this can be expressed like this so that it runs
faster and is robust.

    >>> browser.open('http://launchpad.dev/~launchpad')
    >>> content = find_main_content(browser.contents)
    >>> tag = find_tag_by_id(content, 'recently-approved')
    >>> print tag['class']
    unseen

    >>> tag = find_tag_by_id(content, 'recently-proposed')
    >>> print tag['class']
    unseen

.

review: Approve (code and js)
Revision history for this message
Martin Albisetti (beuno) wrote :

Thanks Edwin. The dialog now looks better, minus an alignment issue between the text and the icon. If you could top-align the text with the icon, it would be super great.

The only remaining issue for me is the fact that the persons' name doesn't appear on the page grayed out while it's being saved, like everywhere else.

Could you factor in that behavior?

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

> Thanks Edwin. The dialog now looks better, minus an alignment issue between
> the text and the icon. If you could top-align the text with the icon, it would
> be super great.
>
> The only remaining issue for me is the fact that the persons' name doesn't
> appear on the page grayed out while it's being saved, like everywhere else.
>
> Could you factor in that behavior?

Martin, I've run into a snag adding the grayed out team/person with a spinner. A team may be added to the "Latest members" or the "Latest invited" list depending on whether the user is also an admin on the team being added as a member. Also, the "Latest members" and "Latest invited" labels may need to be unhidden and presumably re-hidden if they are empty at the end of the process. It seems like a bad idea to make another REST request to gather the information just to put the spinner in the right spot.

Revision history for this message
Guilherme Salgado (salgado) wrote :
Download full text (3.7 KiB)

On Mon, 2009-12-21 at 20:54 +0000, Edwin Grubbs wrote:
> Here is the incremental diff that addresses both Salgado's and
> Martin's comments.

Looks good to me, but there's some code that was apparently written to
test corner cases and left behind accidentally. See below

>
> {{{
> === modified file 'lib/canonical/launchpad/javascript/lp/errors.js'
> --- lib/canonical/launchpad/javascript/lp/errors.js 2009-11-23 19:29:02 +0000
> +++ lib/canonical/launchpad/javascript/lp/errors.js 2009-12-21 20:33:38 +0000
> @@ -69,7 +69,7 @@
> * @param flash_node {Node} The node to red flash.
> * @param msg {String} The message to display.
> */
> -var display_error = function(flash_node, msg) {
> +Y.lp.display_error = function(flash_node, msg) {
> create_error_overlay();
> maybe_red_flash(flash_node, function(){
> error_overlay.showError(msg);
> @@ -77,5 +77,36 @@
> });
> };
>
> -Y.lp.display_error = display_error;
> -}, "0.1", {"requires":["lazr.formoverlay"]});
> +
> +var info_overlay;
> +/*
> + * Display the form overlay for non-error informational messages.
> + *
> + * @method display_info
> + * @param msg {String} The message to display.
> +*/
> +Y.lp.display_info = function(msg) {
> + if (info_overlay === undefined) {
> + info_overlay = new Y.lazr.PrettyOverlay({
> + //headerContent: '<h2><img src="/@@/info-large"/>Notice</h2>',

Why is this commented out?

> + centered: true,
> + visible: false
> + });
> + info_overlay.render();
> + }
> + var content = Y.Node.create(
> + '<div style="background: url(/@@/info-large) no-repeat; ' +
> + 'min-height: 32px; padding-left: 40px; padding-top: 16px"/></div>');
> + content.appendChild(Y.Node.create(msg));
> + var button_div = Y.Node.create('<div style="text-align: right"></div>');
> + var ok_button = Y.Node.create('<button>OK</button>');
> + ok_button.on('click', function(e) {
> + info_overlay.fire('cancel');
> + });
> + button_div.appendChild(ok_button);
> + content.appendChild(button_div);
> + info_overlay.set('bodyContent', content);
> + info_overlay.show();
> +};
> +
> +}, "0.1", {"requires":["lazr.formoverlay", "lazr.overlay"]});
>
> === modified file 'lib/canonical/launchpad/javascript/registry/team.js'
> --- lib/canonical/launchpad/javascript/registry/team.js 2009-12-18 06:06:30 +0000
> +++ lib/canonical/launchpad/javascript/registry/team.js 2009-12-21 20:35:57 +0000
> @@ -54,13 +54,14 @@
> var members_section, members_ul, count_elem;
> if (did_status_change === false) {
> disable_spinner();
> - Y.lp.display_error(
> - addmember_link,
> + Y.lp.display_info(
> selected_person.title + ' is already ' +
> - current_status + '.');
> + current_status.toLowerCase() +
> + ' as a member of the team.');
> return;
> }
> var is_team = false;
> + current_status = 'foo';

!?

I really hope your windmill te...

Read more...

Revision history for this message
Martin Albisetti (beuno) wrote :

This is a pattern we will need to solve many times, so lets land this now, and start a discussion on how to fix it in a more general manner.

Thanks Edwin, this seems good to land for me.

review: Approve (ui)
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

I removed the commented out headerContent, which was unnecessary. I also removed the debugging code that set current_status to 'foo'.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/icing/style-3-0.css'
2--- lib/canonical/launchpad/icing/style-3-0.css 2009-12-21 15:44:34 +0000
3+++ lib/canonical/launchpad/icing/style-3-0.css 2010-01-04 18:54:21 +0000
4@@ -215,6 +215,9 @@
5 font-weight: bold;
6 }
7
8+.unseen {display: none;}
9+
10+
11 /* Common presentations */
12 div.see-all {
13 font-size: 123.1%; /* Same as a h2 */
14
15=== modified file 'lib/canonical/launchpad/icing/style.css'
16--- lib/canonical/launchpad/icing/style.css 2009-12-19 19:00:37 +0000
17+++ lib/canonical/launchpad/icing/style.css 2010-01-04 18:54:21 +0000
18@@ -428,8 +428,6 @@
19 fieldset .expanded {display: block;}
20 fieldset.collapsible legend {font-weight: normal;}
21
22-.unseen {display: none;}
23-
24 form.primary.search {
25 margin-bottom: 2em;
26 }
27
28=== modified file 'lib/canonical/launchpad/javascript/code/codereview.js'
29--- lib/canonical/launchpad/javascript/code/codereview.js 2010-01-04 18:54:20 +0000
30+++ lib/canonical/launchpad/javascript/code/codereview.js 2010-01-04 18:54:21 +0000
31@@ -22,12 +22,13 @@
32 var link = Y.one('#request-review');
33 if (link !== null) {
34 link.addClass('js-action');
35- /* XXX: salgado, 2009-11-11: This will cause the picker to be
36- * recreated every time the user clicks on the link. Although that
37- * makes it unnecessary to have the widget cleared, it makes it
38- * impossible to persist the state of the picker between clicks on the
39- * link. We should probably have a policy to enforce that we
40- * just hide/show widgets when a link is clicked more than once,
41+ /* XXX: salgado 2009-11-11 bug=497603
42+ * This will cause the picker to be recreated every time the
43+ * user clicks on the link. Although that makes it unnecessary
44+ * to have the widget cleared, it makes it impossible to persist
45+ * the state of the picker between clicks on the link. We
46+ * should probably have a policy to enforce that we just
47+ * hide/show widgets when a link is clicked more than once,
48 * instead of recreating the widgets every time.
49 */
50 link.on('click', show_request_review_form);
51
52=== modified file 'lib/canonical/launchpad/javascript/lp/errors.js'
53--- lib/canonical/launchpad/javascript/lp/errors.js 2009-11-23 19:29:02 +0000
54+++ lib/canonical/launchpad/javascript/lp/errors.js 2010-01-04 18:54:21 +0000
55@@ -69,7 +69,7 @@
56 * @param flash_node {Node} The node to red flash.
57 * @param msg {String} The message to display.
58 */
59-var display_error = function(flash_node, msg) {
60+Y.lp.display_error = function(flash_node, msg) {
61 create_error_overlay();
62 maybe_red_flash(flash_node, function(){
63 error_overlay.showError(msg);
64@@ -77,5 +77,35 @@
65 });
66 };
67
68-Y.lp.display_error = display_error;
69-}, "0.1", {"requires":["lazr.formoverlay"]});
70+
71+var info_overlay;
72+/*
73+ * Display the form overlay for non-error informational messages.
74+ *
75+ * @method display_info
76+ * @param msg {String} The message to display.
77+*/
78+Y.lp.display_info = function(msg) {
79+ if (info_overlay === undefined) {
80+ info_overlay = new Y.lazr.PrettyOverlay({
81+ centered: true,
82+ visible: false
83+ });
84+ info_overlay.render();
85+ }
86+ var content = Y.Node.create(
87+ '<div style="background: url(/@@/info-large) no-repeat; ' +
88+ 'min-height: 32px; padding-left: 40px; padding-top: 16px"/></div>');
89+ content.appendChild(Y.Node.create(msg));
90+ var button_div = Y.Node.create('<div style="text-align: right"></div>');
91+ var ok_button = Y.Node.create('<button>OK</button>');
92+ ok_button.on('click', function(e) {
93+ info_overlay.fire('cancel');
94+ });
95+ button_div.appendChild(ok_button);
96+ content.appendChild(button_div);
97+ info_overlay.set('bodyContent', content);
98+ info_overlay.show();
99+};
100+
101+}, "0.1", {"requires":["lazr.formoverlay", "lazr.overlay"]});
102
103=== modified file 'lib/canonical/launchpad/javascript/registry/team.js'
104--- lib/canonical/launchpad/javascript/registry/team.js 2010-01-04 18:54:20 +0000
105+++ lib/canonical/launchpad/javascript/registry/team.js 2010-01-04 18:54:21 +0000
106@@ -15,87 +15,121 @@
107 * @method setup_add_member_handler
108 */
109 module.setup_add_member_handler = function() {
110+ if (Y.UA.ie) {
111+ return;
112+ }
113+
114 var config = {
115 header: 'Add a member',
116 step_title: 'Search',
117 picker_activator: '.menu-link-add_member'
118 };
119
120- var picker = Y.lp.picker.create(
121- 'ValidTeamMember',
122- function(result) { _add_member(result); },
123- config);
124+ Y.lp.picker.create('ValidTeamMember', _add_member, config);
125 };
126
127-var _add_member = function(result) {
128- var spinner = Y.one('#add-member-spinner');
129- var addmember_link = Y.one('.menu-link-add_member');
130- addmember_link.setStyle('display', 'none');
131- spinner.setStyle('display', 'inline');
132- function disable_spinner() {
133- addmember_link.setStyle('display', 'inline');
134- spinner.setStyle('display', 'none');
135- }
136+var _add_member = function(selected_person) {
137+ var box = Y.one('#membership');
138+ var spinner = box.query('#add-member-spinner');
139+ var addmember_link = box.query('.menu-link-add_member');
140+ addmember_link.addClass('unseen');
141+ spinner.removeClass('unseen');
142+ var disable_spinner = function() {
143+ addmember_link.removeClass('unseen');
144+ spinner.addClass('unseen');
145+ };
146 lp_client = new LP.client.Launchpad();
147
148 var error_handler = new LP.client.ErrorHandler();
149 error_handler.clearProgressUI = disable_spinner;
150 error_handler.showError = function(error_msg) {
151- Y.lp.display_error(Y.one('.menu-link-add_member'), error_msg);
152+ Y.lp.display_error(addmember_link, error_msg);
153 };
154
155- config = {
156+ addmember_config = {
157 on: {
158- success: function(member_added) {
159- if (!member_added) {
160- disable_spinner();
161- alert('Already a member.');
162- return;
163- }
164- if (result.css.match("team")) {
165- disable_spinner();
166- alert('This is a team');
167- return;
168- }
169- var members_section = Y.one('#recently-approved');
170- var members_ul = Y.one('#recently-approved-ul');
171+ success: function(change_and_status) {
172+ var did_status_change = change_and_status[0];
173+ var current_status = change_and_status[1];
174+ var members_section, members_ul, count_elem;
175+ if (did_status_change === false) {
176+ disable_spinner();
177+ Y.lp.display_info(
178+ selected_person.title + ' is already ' +
179+ current_status.toLowerCase() +
180+ ' as a member of the team.');
181+ return;
182+ }
183+
184+ if (current_status == 'Invited') {
185+ members_section = box.query('#recently-invited');
186+ members_ul = box.query('#recently-invited-ul');
187+ count_elem = box.query('#invited-member-count');
188+ } else if (current_status == 'Proposed') {
189+ members_section = box.query('#recently-proposed');
190+ members_ul = box.query('#recently-proposed-ul');
191+ count_elem = box.query('#proposed-member-count');
192+ } else if (current_status == 'Approved') {
193+ members_section = box.query('#recently-approved');
194+ members_ul = box.query('#recently-approved-ul');
195+ count_elem = box.query('#approved-member-count');
196+ } else {
197+ Y.lp.display_error(
198+ addmember_link,
199+ 'Unexpected status: ' + current_status);
200+ return;
201+ }
202 var first_node = members_ul.get('firstChild');
203- config = {
204+
205+ var xhtml_person_handler = function(person_html) {
206+ if (count_elem === null && current_status == 'Invited') {
207+ count_elem = Y.Node.create(
208+ '<strong id="invited-member-count">' +
209+ '1</strong>');
210+ var count_box = Y.one(
211+ '#membership #membership-counts');
212+ count_box.append(Y.Node.create(
213+ '<span>, </span>'));
214+ count_box.append(count_elem);
215+ count_box.append(Y.Node.create(
216+ '<span> <a href="/+members#invited">' +
217+ 'invited members</a></span>'));
218+ } else {
219+ var count = count_elem.get('innerHTML');
220+ count = parseInt(count, 10) + 1;
221+ count_elem.set('innerHTML', count);
222+ }
223+ person_repr = Y.Node.create(
224+ '<li>' + person_html + '</li>');
225+ members_section.removeClass('unseen');
226+ members_ul.insertBefore(person_repr, first_node);
227+ anim = Y.lazr.anim.green_flash({node: person_repr});
228+ anim.run();
229+ disable_spinner();
230+ };
231+
232+ xhtml_person_config = {
233 on: {
234- success: function(person_html) {
235- var total_members = Y.one(
236- '#member-count').get('innerHTML');
237- total_members = parseInt(total_members, 10) + 1;
238- Y.one('#member-count').set(
239- 'innerHTML', total_members);
240- person_repr = Y.Node.create(
241- '<li>' + person_html + '</li>');
242- members_section.setStyle('visibility', 'visible');
243- members_ul.insertBefore(
244- person_repr, first_node);
245- anim = Y.lazr.anim.green_flash(
246- {node: person_repr});
247- anim.run();
248- disable_spinner();
249- },
250+ success: xhtml_person_handler,
251 failure: error_handler.getFailureHandler()
252 },
253 accept: LP.client.XHTML
254 };
255- lp_client.get(result.api_uri, config);
256+ lp_client.get(selected_person.api_uri, xhtml_person_config);
257 },
258 failure: error_handler.getFailureHandler()
259 },
260 parameters: {
261- // XXX: Why do I always have to get absolute URIs out of the URIs
262+ // XXX: EdwinGrubbs 2009-12-16 bug=497602
263+ // Why do I always have to get absolute URIs out of the URIs
264 // in the picker's result/client.links?
265 reviewer: LP.client.get_absolute_uri(LP.client.links.me),
266- person: LP.client.get_absolute_uri(result.api_uri)
267+ person: LP.client.get_absolute_uri(selected_person.api_uri)
268 }
269 };
270
271 lp_client.named_post(
272- LP.client.cache.context.self_link, 'addMember', config);
273+ LP.client.cache.context.self_link, 'addMember', addmember_config);
274 };
275
276 }, '0.1', {requires: [
277
278=== modified file 'lib/lp/registry/browser/person.py'
279--- lib/lp/registry/browser/person.py 2010-01-04 18:54:20 +0000
280+++ lib/lp/registry/browser/person.py 2010-01-04 18:54:21 +0000
281@@ -95,6 +95,7 @@
282 from itertools import chain
283 from operator import attrgetter, itemgetter
284 from textwrap import dedent
285+from storm.zope.interfaces import IResultSet
286
287 from zope.error.interfaces import IErrorReportingUtility
288 from zope.app.form.browser import TextAreaWidget, TextWidget
289@@ -227,13 +228,8 @@
290 logoutPerson, allowUnauthenticatedSession)
291 from canonical.launchpad.webapp.menu import get_current_view
292 from canonical.launchpad.webapp.publisher import LaunchpadView
293-<<<<<<< TREE
294-from canonical.launchpad.webapp.tales import (
295- DateTimeFormatterAPI, FormattersAPI)
296-=======
297-from canonical.launchpad.webapp.tales import (
298- DateTimeFormatterAPI, PersonFormatterAPI)
299->>>>>>> MERGE-SOURCE
300+from canonical.launchpad.webapp.tales import (
301+ DateTimeFormatterAPI, FormattersAPI, PersonFormatterAPI)
302 from lazr.uri import URI, InvalidURIError
303
304 from canonical.launchpad import _
305@@ -2489,11 +2485,7 @@
306
307 @property
308 def next_url(self):
309-<<<<<<< TREE
310 """Redirect to the +languages page if request originated there."""
311-=======
312- """Redirect back to the originating page."""
313->>>>>>> MERGE-SOURCE
314 redirection_url = self.request.get('redirection_url')
315 if redirection_url:
316 return redirection_url
317@@ -2501,11 +2493,7 @@
318
319 @property
320 def cancel_url(self):
321-<<<<<<< TREE
322 """Redirect to the +languages page if request originated there."""
323-=======
324- """Redirect back to the originating page."""
325->>>>>>> MERGE-SOURCE
326 redirection_url = self.getRedirectionURL()
327 if redirection_url:
328 return redirection_url
329@@ -2745,6 +2733,13 @@
330 orderBy='-TeamMembership.date_proposed')
331 return members[:5]
332
333+ @cachedproperty
334+ def recently_invited_members(self):
335+ members = self.context.getMembersByStatus(
336+ TeamMembershipStatus.INVITED,
337+ orderBy='-TeamMembership.date_proposed')
338+ return members[:5]
339+
340 @property
341 def recently_approved_hidden(self):
342 """Optionally hide the div.
343@@ -2752,8 +2747,8 @@
344 The AJAX on the page needs the elements to be present
345 but hidden in case it adds a member to the list.
346 """
347- if self.recently_approved_members.count() == 0:
348- return 'visibility: collapse'
349+ if IResultSet(self.recently_approved_members).is_empty():
350+ return 'unseen'
351 else:
352 return ''
353
354@@ -2764,8 +2759,20 @@
355 The AJAX on the page needs the elements to be present
356 but hidden in case it adds a member to the list.
357 """
358- if self.recently_proposed_members.count() == 0:
359- return 'visibility: collapse'
360+ if IResultSet(self.recently_proposed_members).is_empty():
361+ return 'unseen'
362+ else:
363+ return ''
364+
365+ @property
366+ def recently_invited_hidden(self):
367+ """Optionally hide the div.
368+
369+ The AJAX on the page needs the elements to be present
370+ but hidden in case it adds a member to the list.
371+ """
372+ if IResultSet(self.recently_invited_members).is_empty():
373+ return 'unseen'
374 else:
375 return ''
376
377@@ -4241,9 +4248,14 @@
378 def continue_action(self, action, data):
379 """Make the selected teams join this team."""
380 context = self.context
381+ is_admin = check_permission('launchpad.Admin', context)
382 for team in data['teams']:
383- team.join(context, requester=self.user)
384- if context.subscriptionpolicy == TeamSubscriptionPolicy.MODERATED:
385+ if is_admin:
386+ context.addMember(team, reviewer=self.user)
387+ else:
388+ team.join(context, requester=self.user)
389+ if (context.subscriptionpolicy == TeamSubscriptionPolicy.MODERATED
390+ and not is_admin):
391 msg = 'proposed to this team.'
392 else:
393 msg = 'added to this team.'
394
395=== modified file 'lib/lp/registry/browser/tests/mailinglist-views.txt'
396--- lib/lp/registry/browser/tests/mailinglist-views.txt 2009-12-03 19:12:02 +0000
397+++ lib/lp/registry/browser/tests/mailinglist-views.txt 2010-01-04 18:54:21 +0000
398@@ -33,7 +33,7 @@
399 No Privileges Person is added as a member of the Aardvarks.
400
401 >>> no_priv = person_set.getByEmail('no-priv@canonical.com')
402- >>> aardvarks.addMember(no_priv, sample_person)
403+ >>> ignored = aardvarks.addMember(no_priv, sample_person)
404
405 But regular members can't access the +mailinglist page.
406
407@@ -52,8 +52,9 @@
408 >>> team_membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
409 ... no_priv, aardvarks)
410 >>> team_membership.status
411- <DBItem TeamMembershipStatus.APPROVED, (2) Approved>
412- >>> team_membership.setStatus(TeamMembershipStatus.ADMIN, sample_person)
413+ <DBItem TeamMembershipStatus.APPROVED...
414+ >>> ignored = team_membership.setStatus(
415+ ... TeamMembershipStatus.ADMIN, sample_person)
416
417 Now No Privileges Person has permission to request mailing lists.
418
419@@ -208,7 +209,7 @@
420
421 A non-owner member can see information about the mailing list.
422
423- >>> pmt.addMember(sample_person, owner)
424+ >>> ignored = pmt.addMember(sample_person, owner)
425 >>> login('test@canonical.com')
426 >>> view = create_initialized_view(pmt, '+index')
427 >>> print view.archive_url
428
429=== modified file 'lib/lp/registry/interfaces/person.py'
430--- lib/lp/registry/interfaces/person.py 2009-12-09 23:11:31 +0000
431+++ lib/lp/registry/interfaces/person.py 2010-01-04 18:54:21 +0000
432@@ -1406,23 +1406,33 @@
433 may_subscribe_to_list=True):
434 """Add the given person as a member of this team.
435
436- If the given person is already a member of this team we'll simply
437- change its membership status. Otherwise a new TeamMembership is
438- created with the given status.
439-
440- If the person is actually a team and force_team_add is False, the
441- team will actually be invited to join this one. Otherwise the team
442- is added as if it were a person.
443-
444- If the person is not a team, and may_subscribe_to_list
445- is True, then the person may be subscribed to the team's
446- mailing list, depending on the list status and the person's
447- auto-subscribe settings.
448-
449- The given status must be either Approved, Proposed or Admin.
450-
451- The reviewer is the user who made the given person a member of this
452- team.
453+ :param person: If the given person is already a member of this
454+ team we'll simply change its membership status. Otherwise a new
455+ TeamMembership is created with the given status.
456+
457+ :param reviewer: The user who made the given person a member of this
458+ team.
459+
460+ :param comment: String that will be assigned to the
461+ proponent_comment, reviwer_comment, or acknowledger comment.
462+
463+ :param status: `TeamMembershipStatus` value must be either
464+ Approved, Proposed or Admin.
465+
466+ :param force_team_add: If the person is actually a team and
467+ force_team_add is False, the team will actually be invited to
468+ join this one. Otherwise the team is added as if it were a
469+ person.
470+
471+ :param may_subscribe_to_list: If the person is not a team, and
472+ may_subscribe_to_list is True, then the person may be subscribed
473+ to the team's mailing list, depending on the list status and the
474+ person's auto-subscribe settings.
475+
476+ :return: A tuple containing a boolean indicating when the
477+ membership status changed and the current `TeamMembershipStatus`.
478+ This depends on the desired status passed as an argument, the
479+ subscription policy and the user's priveleges.
480 """
481
482 def deactivateAllMembers(comment, reviewer):
483
484=== modified file 'lib/lp/registry/model/person.py'
485--- lib/lp/registry/model/person.py 2010-01-04 18:54:20 +0000
486+++ lib/lp/registry/model/person.py 2010-01-04 18:54:21 +0000
487@@ -1231,12 +1231,15 @@
488 # By default, teams can only be invited as members, meaning that
489 # one of the team's admins will have to accept the invitation
490 # before the team is made a member. If force_team_add is True,
491- # though, then we'll add a team as if it was a person.
492- if not force_team_add:
493+ # or the user is also an admin of the proposed member, then
494+ # we'll add a team as if it was a person.
495+ is_reviewer_admin_of_new_member = (
496+ person in reviewer.getAdministratedTeams())
497+ if not force_team_add and not is_reviewer_admin_of_new_member:
498 status = TeamMembershipStatus.INVITED
499 event = TeamInvitationEvent
500
501- member_added = True
502+ status_changed = True
503 expires = self.defaultexpirationdate
504 tm = TeamMembership.selectOneBy(person=person, team=self)
505 if tm is None:
506@@ -1251,12 +1254,12 @@
507 # We can't use tm.setExpirationDate() here because the reviewer
508 # here will be the member themselves when they join an OPEN team.
509 tm.dateexpires = expires
510- member_added = tm.setStatus(status, reviewer, comment)
511+ status_changed = tm.setStatus(status, reviewer, comment)
512
513 if not person.is_team and may_subscribe_to_list:
514 person.autoSubscribeToMailingList(self.mailing_list,
515 requester=reviewer)
516- return member_added
517+ return (status_changed, tm.status)
518
519 # The three methods below are not in the IPerson interface because we want
520 # to protect them with a launchpad.Edit permission. We could do that by
521
522=== modified file 'lib/lp/registry/model/teammembership.py'
523--- lib/lp/registry/model/teammembership.py 2010-01-04 18:54:20 +0000
524+++ lib/lp/registry/model/teammembership.py 2010-01-04 18:54:21 +0000
525@@ -318,7 +318,6 @@
526 "%(team)s is a member of %(person)s."
527 % dict(person=self.person.name, team=self.team.name))
528
529-
530 old_status = self.status
531 self.status = status
532
533@@ -367,17 +366,10 @@
534 # When a member proposes himself, a more detailed notification is
535 # sent to the team admins by a subscriber of JoinTeamEvent; that's
536 # why we don't send anything here.
537-<<<<<<< TREE
538- if self.person == self.last_changed_by and self.status == proposed:
539- return
540-
541- if not silent:
542- self._sendStatusChangeNotification(old_status)
543-=======
544- if self.person != self.last_changed_by or self.status != proposed:
545+ if ((self.person != self.last_changed_by or self.status != proposed)
546+ and not silent):
547 self._sendStatusChangeNotification(old_status)
548 return True
549->>>>>>> MERGE-SOURCE
550
551 def _sendStatusChangeNotification(self, old_status):
552 """Send a status change notification to all team admins and the
553
554=== modified file 'lib/lp/registry/stories/team/xx-team-home.txt'
555--- lib/lp/registry/stories/team/xx-team-home.txt 2009-11-22 15:43:16 +0000
556+++ lib/lp/registry/stories/team/xx-team-home.txt 2010-01-04 18:54:21 +0000
557@@ -13,19 +13,24 @@
558
559 >>> print extract_text(
560 ... find_tag_by_id(browser.contents, 'recently-approved'))
561- Recently approved
562+ Latest members
563 Warty Gnome Team
564 Daniel Silverstone
565 Celso Providelo
566 Steve Alexander...
567
568 >>> print extract_text(
569- ... find_tag_by_id(browser.contents, 'recently-applied'))
570+ ... find_tag_by_id(browser.contents, 'recently-proposed'))
571 Pending approval
572 Sample Person
573 Andrew Bennetts
574
575 >>> print extract_text(
576+ ... find_tag_by_id(browser.contents, 'recently-invited'))
577+ Latest invited
578+ Warty Security Team
579+
580+ >>> print extract_text(
581 ... find_tag_by_id(browser.contents, 'team-owner'))
582 Owner:
583 Mark Shuttleworth
584@@ -87,15 +92,21 @@
585 itself is not a member of any other team.
586 Show received invitations
587
588-If the team does not have any recently approved or proposed members,
589-the recent members sections are not displayed:
590+If the team does not have any recently approved, proposed, or invited
591+members, the empty lists are hidden using the "unseen" css class:
592
593 >>> browser.open('http://launchpad.dev/~launchpad')
594- >>> print find_tag_by_id(browser.contents, 'recently-approved')
595- None
596-
597- >>> print find_tag_by_id(browser.contents, 'recently-applied')
598- None
599+ >>> tag = find_tag_by_id(browser.contents, 'recently-approved')
600+ >>> print tag['class']
601+ unseen
602+
603+ >>> tag = find_tag_by_id(browser.contents, 'recently-proposed')
604+ >>> print tag['class']
605+ unseen
606+
607+ >>> tag = find_tag_by_id(browser.contents, 'recently-invited')
608+ >>> print tag['class']
609+ unseen
610
611 In the above case there's no user logged in, so it doesn't actually show
612 what's the user's involvement with the team. If the user logs in, he'll see
613@@ -187,7 +198,7 @@
614 >>> owner_browser = setupBrowser(auth="Basic foo.bar@canonical.com:test")
615 >>> owner_browser.open('http://launchpad.dev/~ubuntu-team')
616 >>> print extract_text(
617- ... find_tag_by_id(owner_browser.contents, 'recently-applied'))
618+ ... find_tag_by_id(owner_browser.contents, 'recently-proposed'))
619 Pending approval
620 Sample Person
621 Andrew Bennetts
622
623=== modified file 'lib/lp/registry/stories/team/xx-team-membership.txt'
624--- lib/lp/registry/stories/team/xx-team-membership.txt 2009-11-22 15:43:16 +0000
625+++ lib/lp/registry/stories/team/xx-team-membership.txt 2010-01-04 18:54:21 +0000
626@@ -174,6 +174,19 @@
627 >>> message in second_browser.contents
628 True
629
630+An admin can see the former members of the team.
631+
632+ >>> browser.open('http://launchpad.dev/~name18/+members')
633+ >>> print extract_text(
634+ ... find_tag_by_id(browser.contents, 'inactivemembers'))
635+ Name Joined in Status...
636+
637+Other users cannot see the former members of the team.
638+
639+ >>> user_browser.open('http://launchpad.dev/~name18/+members')
640+ >>> print find_tag_by_id(user_browser.contents, 'inactivemembers')
641+ None
642+
643
644 Team Participation page
645 =======================
646
647=== modified file 'lib/lp/registry/templates/team-index.pt'
648--- lib/lp/registry/templates/team-index.pt 2010-01-04 18:54:20 +0000
649+++ lib/lp/registry/templates/team-index.pt 2010-01-04 18:54:21 +0000
650@@ -86,7 +86,7 @@
651 <div class="first yui-u">
652 <metal:details use-macro="context/@@+person-macros/team-details" />
653 </div>
654- <div class="first yui-u">
655+ <div class="yui-u">
656 <div tal:content="structure context/@@+portlet-membership" />
657 </div>
658 </div>
659
660=== modified file 'lib/lp/registry/templates/team-members.pt'
661--- lib/lp/registry/templates/team-members.pt 2009-10-26 14:23:12 +0000
662+++ lib/lp/registry/templates/team-members.pt 2010-01-04 18:54:21 +0000
663@@ -207,57 +207,59 @@
664
665 <li tal:condition="view/inactive_memberships/batch/total"
666 tal:define="inactive_batch nocall:view/inactive_memberships/currentBatch">
667- <a name="former"></a>
668- <h2>Former members</h2>
669-
670- <p>
671- These are the members whose subscriptions have expired, or were
672- deactivated by themselves or by a team administrator.
673- </p>
674-
675- <div class="lesser" id="inactive-top-navigation">
676- <tal:navigation
677- content="structure view/inactive_memberships/@@+navigation-links-upper" />
678- </div>
679- <table class="listing sortable" id="inactivemembers">
680- <thead>
681- <tr>
682- <th>Name</th>
683- <th>Joined in</th>
684- <th>Status</th>
685- <th>&nbsp;</th>
686- </tr>
687- </thead>
688- <tbody>
689- <tr tal:repeat="membership inactive_batch">
690- <tal:block define="member membership/person">
691- <td>
692- <a tal:attributes="href member/fmt:url"
693- tal:content="member/displayname" />
694- </td>
695- <td
696- tal:content="membership/datejoined/fmt:date"
697- >2005-04-01</td>
698-
699- <td tal:condition="membership/isExpired">
700- Expired on
701- <span tal:replace="membership/dateexpires/fmt:date" />
702- </td>
703- <td
704- tal:condition="not: membership/isExpired"
705- tal:content="membership/status/title" />
706- <td tal:condition="user_can_edit_memberships">
707- <a tal:attributes="href membership/fmt:url"
708- ><img src="/@@/edit"
709- title="Change membership duration or status" /></a>
710- </td>
711- </tal:block>
712- </tr>
713- </tbody>
714- </table>
715- <div class="lesser">
716- <tal:navigation
717- content="structure view/inactive_memberships/@@+navigation-links-lower" />
718+ <div tal:condition="user_can_edit_memberships">
719+ <a name="former"></a>
720+ <h2>Former members</h2>
721+
722+ <p>
723+ These are the members whose subscriptions have expired, or were
724+ deactivated by themselves or by a team administrator.
725+ </p>
726+
727+ <div class="lesser" id="inactive-top-navigation">
728+ <tal:navigation
729+ content="structure view/inactive_memberships/@@+navigation-links-upper" />
730+ </div>
731+ <table class="listing sortable" id="inactivemembers">
732+ <thead>
733+ <tr>
734+ <th>Name</th>
735+ <th>Joined in</th>
736+ <th>Status</th>
737+ <th>&nbsp;</th>
738+ </tr>
739+ </thead>
740+ <tbody>
741+ <tr tal:repeat="membership inactive_batch">
742+ <tal:block define="member membership/person">
743+ <td>
744+ <a tal:attributes="href member/fmt:url"
745+ tal:content="member/displayname" />
746+ </td>
747+ <td
748+ tal:content="membership/datejoined/fmt:date"
749+ >2005-04-01</td>
750+
751+ <td tal:condition="membership/isExpired">
752+ Expired on
753+ <span tal:replace="membership/dateexpires/fmt:date" />
754+ </td>
755+ <td
756+ tal:condition="not: membership/isExpired"
757+ tal:content="membership/status/title" />
758+ <td tal:condition="user_can_edit_memberships">
759+ <a tal:attributes="href membership/fmt:url"
760+ ><img src="/@@/edit"
761+ title="Change membership duration or status" /></a>
762+ </td>
763+ </tal:block>
764+ </tr>
765+ </tbody>
766+ </table>
767+ <div class="lesser">
768+ <tal:navigation
769+ content="structure view/inactive_memberships/@@+navigation-links-lower" />
770+ </div>
771 </div>
772 </li>
773 </ul>
774
775=== modified file 'lib/lp/registry/templates/team-portlet-membership.pt'
776--- lib/lp/registry/templates/team-portlet-membership.pt 2010-01-04 18:54:20 +0000
777+++ lib/lp/registry/templates/team-portlet-membership.pt 2010-01-04 18:54:21 +0000
778@@ -15,24 +15,24 @@
779
780 <div id="membership-summary" class="portletBody portletContent"
781 style="margin-bottom: 1.5em;">
782- <div id="membership-summary">
783- <div>
784+ <div>
785+ <div id="membership-counts">
786 <img src="/@@/team" alt="team" />
787- <strong id="member-count">
788+ <strong id="approved-member-count">
789 <tal:active content="context/all_member_count" />
790 </strong>
791 <a tal:attributes="href string:${context/fmt:url/+members}#active"
792 >active members</a><tal:invited
793 define="invited_member_count context/invited_member_count"
794 condition="invited_member_count">,
795- <strong>
796+ <strong id="invited-member-count">
797 <tal:invited_count content="context/invited_member_count" />
798 </strong>
799 <a tal:attributes="href string:${context/fmt:url/+members}#invited"
800 >invited members</a></tal:invited><tal:proposed
801 define="proposed_member_count context/proposed_member_count"
802 condition="proposed_member_count">,
803- <strong>
804+ <strong id="proposed-member-count">
805 <tal:proposed_count content="proposed_member_count" />
806 </strong>
807 <a tal:attributes="href string:${context/fmt:url/+members}#proposed"
808@@ -95,37 +95,40 @@
809 <table style="margin: 0px 0px .5em 0px;">
810 <tr>
811 <td style="padding: 0px 1em 1em 0px;"
812- tal:define="link context/menu:overview/add_member">
813- <span id="add-member-spinner" class="update-in-progress-message"
814- style="display: none">
815+ tal:define="link context/menu:overview/add_member"
816+ tal:condition="link/enabled">
817+ <span id="add-member-spinner"
818+ class="unseen update-in-progress-message">
819 Saving...
820 </span>
821 <tal:add-member replace="structure link/fmt:link-icon" />
822 </td>
823- <td style="padding: 0px 0px 1em 0px;"
824+ <td colspan="2"
825+ style="padding: 0px 0px 1em 0px;"
826 tal:define="link context/menu:overview/mugshots">
827 <tal:mugshots replace="structure link/fmt:link-icon" />
828 </td>
829 </tr>
830+ </table>
831+ <table>
832 <tr>
833- <td style="padding: 3px 3em 0px 0px;">
834- <div id="recently-approved"
835- tal:attributes="style view/recently_approved_hidden">
836- <h3 style="color:black; font-weight:bold; margin: 0px">
837- Latest members
838- </h3>
839- <ul id="recently-approved-ul">
840- <li tal:repeat="person view/recently_approved_members"
841- tal:content="structure person/fmt:link" />
842- </ul>
843- </div>
844+ <td id="recently-approved"
845+ style="padding-right: 1em"
846+ tal:attributes="class view/recently_approved_hidden">
847+ <h3 style="color:black; font-weight:bold; margin: 0px">
848+ Latest members
849+ </h3>
850+ <ul id="recently-approved-ul">
851+ <li tal:repeat="person view/recently_approved_members"
852+ tal:content="structure person/fmt:link" />
853+ </ul>
854 </td>
855- <td style="padding: 0px;" id="recently-applied"
856- tal:condition="view/recently_proposed_members">
857+ <td id="recently-proposed"
858+ tal:attributes="class view/recently_proposed_hidden">
859 <h3 style="color:black; font-weight:bold; margin: 0px">
860 Pending approval
861 </h3>
862- <ul>
863+ <ul id="recently-proposed-ul">
864 <li tal:repeat="person view/recently_proposed_members"
865 tal:content="structure person/fmt:link" />
866 </ul>
867@@ -136,6 +139,16 @@
868 </td>
869 </tr>
870 </table>
871+ <div id="recently-invited"
872+ tal:attributes="class view/recently_invited_hidden">
873+ <h3 style="color:black; font-weight:bold; margin: 0px">
874+ Latest invited
875+ </h3>
876+ <ul id="recently-invited-ul">
877+ <li tal:repeat="person view/recently_invited_members"
878+ tal:content="structure person/fmt:link" />
879+ </ul>
880+ </div>
881 </tal:can-view>
882 </div>
883 </tal:root>
884
885=== added file 'lib/lp/registry/windmill/tests/test_team_index.py'
886--- lib/lp/registry/windmill/tests/test_team_index.py 1970-01-01 00:00:00 +0000
887+++ lib/lp/registry/windmill/tests/test_team_index.py 2010-01-04 18:54:21 +0000
888@@ -0,0 +1,74 @@
889+# Copyright 2009 Canonical Ltd. This software is licensed under the
890+# GNU Affero General Public License version 3 (see the file LICENSE).
891+
892+"""Test team index page."""
893+
894+__metaclass__ = type
895+__all__ = []
896+
897+import unittest
898+
899+from windmill.authoring import WindmillTestClient
900+
901+from canonical.launchpad.windmill.testing import lpuser
902+from canonical.launchpad.windmill.testing.widgets import (
903+ search_and_select_picker_widget)
904+
905+from lp.registry.windmill.testing import RegistryWindmillLayer
906+from lp.testing import TestCaseWithFactory
907+
908+
909+class TestTeamIndex(TestCaseWithFactory):
910+ """Test team index page."""
911+
912+ layer = RegistryWindmillLayer
913+
914+ def setUp(self):
915+ self.client = WindmillTestClient(__name__)
916+
917+ def test_addmember(self):
918+ self.client.open(
919+ url=u'http://launchpad.dev:8085/~testing-spanish-team')
920+
921+ lpuser.TRANSLATIONS_ADMIN.ensure_login(self.client)
922+
923+ addmember_xpath = (
924+ '//*[@id="membership"]' +
925+ '//a[text()="Add member" ' +
926+ 'and contains(@class, "js-action")]')
927+ # Add rosetta-admins team as a member.
928+ approved_count_xpath = '//*[@id="approved-member-count"]'
929+ code = "lookupNode({xpath: '%s'}).textContent" % approved_count_xpath
930+ result = self.client.commands.execJS(code=code)
931+ old_approved_count = int(result['result'])
932+
933+ self.client.waits.forElement(xpath=addmember_xpath)
934+ self.client.click(xpath=addmember_xpath)
935+ # Xpath is 1-indexed.
936+ search_and_select_picker_widget(self.client, 'rosetta', 1)
937+
938+ self.client.waits.forElement(
939+ xpath='//ul[@id="recently-approved-ul"]/li[1]/a',
940+ option='href|~rosetta-admins')
941+
942+ self.client.asserts.assertText(
943+ xpath=approved_count_xpath,
944+ validator=str(old_approved_count+1))
945+
946+ # Add another team as a member.
947+ invited_count_xpath = '//*[@id="invited-member-count"]'
948+ self.client.asserts.assertNotNode(xpath=invited_count_xpath)
949+ self.client.click(xpath=addmember_xpath)
950+ search_and_select_picker_widget(self.client, 'simple', 1)
951+
952+ self.client.waits.forElement(
953+ xpath='//ul[@id="recently-invited-ul"]/li[1]/a',
954+ option='href|~simple-team')
955+
956+ self.client.asserts.assertText(
957+ xpath=invited_count_xpath,
958+ validator="1")
959+
960+
961+def test_suite():
962+ return unittest.TestLoader().loadTestsFromName(__name__)