Merge lp:~frankban/juju-quickstart/env-manage-edit into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 41
Proposed branch: lp:~frankban/juju-quickstart/env-manage-edit
Merge into: lp:juju-quickstart
Diff against target: 1343 lines (+1019/-45)
11 files modified
quickstart/app.py (+4/-2)
quickstart/cli/base.py (+4/-5)
quickstart/cli/forms.py (+215/-0)
quickstart/cli/ui.py (+3/-0)
quickstart/cli/views.py (+134/-9)
quickstart/manage.py (+5/-4)
quickstart/models/envs.py (+14/-3)
quickstart/tests/cli/test_base.py (+9/-9)
quickstart/tests/cli/test_forms.py (+345/-0)
quickstart/tests/cli/test_views.py (+272/-13)
quickstart/tests/models/test_envs.py (+14/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/env-manage-edit
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+200305@code.launchpad.net

Description of the change

Implement the environment create/edit view.

Add a cli/forms.py module including helpers
to create and handle Urwid forms.

Allow for creating a new environment from the
env_index view.

Implement the env_edit view, used for creating
or updating environments.

Fix a corner case in envs.set_env_data: delete
the env_db default key only if the environments
has been modified.

Tests: `make check`.

QA: start the demo app
(`make` and `./cli-app-demo.py).
Use it to edit existing environments and
to create new ones (ec2 and local).
Check form errors are correctly handled.
Exit the demo app with either ^X or
selecting an environment to use. The
demo app should show changes on exit,
and notify whether a new db has been saved
and/or an environment has been selected.

https://codereview.appspot.com/44750044/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+200305_code.launchpad.net,

Message:
Please take a look.

Description:
Implement the environment create/edit view.

Add a cli/forms.py module including helpers
to create and handle Urwid forms.

Allow for creating a new environment from the
env_index view.

Implement the env_edit view, used for creating
or updating environments.

Fix a corner case in envs.set_env_data: delete
the env_db default key only if the environments
has been modified.

Tests: `make check`.

QA: start the demo app
(`make` and `./cli-app-demo.py).
Use it to edit existing environments and
to create new ones (ec2 and local).
Check form errors are correctly handled.
Exit the demo app with either ^X or
selecting an environment to use. The
demo app should show changes on exit,
and notify whether a new db has been saved
and/or an environment has been selected.

https://code.launchpad.net/~frankban/juju-quickstart/env-manage-edit/+merge/200305

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/44750044/

Affected files (+948, -20 lines):
   A [revision details]
   A quickstart/cli/forms.py
   M quickstart/cli/ui.py
   M quickstart/cli/views.py
   M quickstart/models/envs.py
   A quickstart/tests/cli/test_forms.py
   M quickstart/tests/cli/test_views.py
   M quickstart/tests/models/test_envs.py

Revision history for this message
Francesco Banconi (frankban) wrote :

Sorry for the long diff, tests for views are a little verbose.

https://codereview.appspot.com/44750044/

Revision history for this message
Gary Poster (gary) wrote :
Download full text (3.5 KiB)

QA comments, pre-review:

- Looking very cool.
- I like the fact that I can right click on links to launch them. That
may well be a terminal feature rather than a quickstart or urwid
feature, but it all fits together nicely.
- Trivial, but I continue to be dissatisfied with the footer formatting.
  I don't like the fact that the text in the legend line wraps. For
instance, when creating, I want "field errors" to be on the same line as
the dot it describes. Possible resolutions include the following: (a)
we don't show "juju-quickstart 0.5.0" anywhere; (b) we show it in the
right of the header; or (c) we show it in its own line in the footer.
- It would be nice if you could tab between fields. I assume this is an
Urwid limitation, in which case never mind. Maybe we could clarify in
the footer that arrow keys move between fields?
- It was not clear to me that clicking on "automatically generate" would
generate the admin secret. Could we maybe change that text to "Click
here to automatically generate this value"?
- For the default series, you say that you may leave the series empty.
It would be nice if you explained the semantics of that in the help
text. Am I right that the semantics are that the series of the host
computer is used?
- Should we default the default series value to precise?
- Maybe a bad idea: we could make the "precise," "quantal," and other
default series text clickable. When you click on them, they fill in the
field with the given value. Same could be done with ec2 region.
- Maybe a better idea: Does Urwid support the idea of a combo box,
somehow? Would be nice. :-)
- I wish the two "create" options in the main menu looked more
different. Any bright UX ideas? :-) Right now the whitespace
separating the environments from the creation options don't clarify
their difference sufficiently for my eye.
- Relatedly, the header instructions in the main menu are currently
"Select the Juju environment you want to use." That no longer seems to
describe reality. "Select the Juju environment you want to use, edit,
view, or remove." That's wordy and leaves out the creation options.
"Select an existing Juju environment or create a new one." Maybe that's
good enough? The user can see what to do with the environment once they
select it.
- I know I suggested linking to
https://juju.ubuntu.com/docs/config-aws.html for help, but it is
confusing since the first half of the page is not pertinent to
quickstart. The good stuff begins with "You can retrieve these values
easily from your AWS Management Console at...." My ideas so far include
the following: (a) confer with Nick to see if he has any ideas on
whether we can add an anchor in the page to the part we want, or if we
can have a separate page with just the part we want; (b) include the
information for users actually in the help text for access key ("You can
retrieve these values easily from your AWS Management Console at
http://console.aws.amazon.com. Click on your name in the top-right and
then the "Security Credentials" link from the drop down menu. Under the
"Access Keys" heading click the "Create New Root Key" button. You will
be prompted to "Download Key File" which by def...

Read more...

Revision history for this message
Benji York (benji) wrote :

Looks great.

LGTM-ly, yrs
Benji

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py
File quickstart/cli/forms.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode208
quickstart/cli/forms.py:208: return ui.create_controls(*controls)
This is so small it is almost not worth mentioning, but I thought you
might like it:

return ui.create_controls(
     *(ui.MenuButton(caption, callback) for caption, callback in
actions))

Or if we want to go full-on functional:

return ui.create_controls(*map(ui.MenuButton, actions))

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/ui.py
File quickstart/cli/ui.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/ui.py#newcode49
quickstart/cli/ui.py:49: ('optional status', 'light magenta', 'light
gray'),
Unfortunately the non-256 color terminal colors are not standardized so
I suggest testing on multiple terminals (I can test on xterm, gnome
terminal, and terminator if you want).

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py#newcode347
quickstart/cli/views.py:347: def save(env_data, get_new_env_data):
I like using functions defined in functions like this, but it means that
they are hard to test (or require functional tests instead of unit
tests). Consider making them module-level functions so they can be
tested directly.

https://codereview.appspot.com/44750044/diff/1/quickstart/tests/cli/test_forms.py
File quickstart/tests/cli/test_forms.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/tests/cli/test_forms.py#newcode1
quickstart/tests/cli/test_forms.py:1: # This file is part of the Juju
Quickstart Plugin, which lets users set up a
These tests look really nice. The running comments in each test really
increase understanding of what is going on.

https://codereview.appspot.com/44750044/

Revision history for this message
Gary Poster (gary) wrote :
Download full text (4.5 KiB)

LGTM! Thank you!

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py
File quickstart/cli/forms.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode44
quickstart/cli/forms.py:44: def create_string_widget(field, value,
error):
Nice behavior/approach for the widget generation. I like it.

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode82
quickstart/cli/forms.py:82: if generate_method is not None:
For readability, I wonder if we ought to stick most of this block in a
separate function.

if generate_method is not None:
     widgets.append(
         create_generate_widget(generate_method, edit_widget))

...or similar?

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode98
quickstart/cli/forms.py:98: # edit widget when a choice is clicked.
Hah, ok, cool, that's what I mentioned in my qa. :-)

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode172
quickstart/cli/forms.py:172: urwid.Text(('error', 'please correct the
errors below')),
Nice to have: a count. ("Please correct the 2 errors below")

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode179
quickstart/cli/forms.py:179: # Boolean values as represented as
checkboxes.
s/as/are/

Boolean values are represented a checkboxes

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode195
quickstart/cli/forms.py:195: return dict((key, value_getter()) for key,
value_getter in form.items())
Nice :-)

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py#newcode168
quickstart/cli/views.py:168: for position, env_data in
enumerate(environments):
Just as a way to make the main function smaller, what would you think of
breaking this block out into its own function?

...eh, nevermind. I tried to do it and you need default_found and
focus_position and errors_found, so it doesn't seem like there's much
point.

The function is very clear and linear but I was hoping to make it
shorter by making some nice internal verbs. Maybe you have another
idea? Could be for later, or never. :-)

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py#newcode230
quickstart/cli/views.py:230: def use(env_data):
I wonder if the env_detail function would look less forbidding at first
glance if these internal functions were defined outside (_use,
_set_default, etc.) and then hooked up as partials. I think the verbs
are clear enough that it would read well.

A counter-argument is that you are defining everything locally, so you
can read what is going on inline, and it similar to what people are
accustomed to doing with OO. If env_detail were in its own file as is,
would it look super easy, because it would look similar to a class with
methods? I don't know.

What do you think?

https://codereview.appspot.com/44750044/diff/1/quickstart/tests/cli/test_forms.py
File quickstart/tests/cli/test_forms.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/tests/cli/test_forms.py#newcode1...

Read more...

55. By Francesco Banconi

Form changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :
Download full text (5.8 KiB)

Please take a look.

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py
File quickstart/cli/forms.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode82
quickstart/cli/forms.py:82: if generate_method is not None:
On 2014/01/02 15:19:28, gary.poster wrote:
> For readability, I wonder if we ought to stick most of this block in a
separate
> function.

> if generate_method is not None:
> widgets.append(
> create_generate_widget(generate_method, edit_widget))

> ...or similar?

Done.

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode172
quickstart/cli/forms.py:172: urwid.Text(('error', 'please correct the
errors below')),
On 2014/01/02 15:19:28, gary.poster wrote:
> Nice to have: a count. ("Please correct the 2 errors below")

Done.

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode179
quickstart/cli/forms.py:179: # Boolean values as represented as
checkboxes.
On 2014/01/02 15:19:28, gary.poster wrote:
> s/as/are/

> Boolean values are represented a checkboxes

Done.

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/forms.py#newcode208
quickstart/cli/forms.py:208: return ui.create_controls(*controls)
On 2014/01/02 15:05:32, benji1 wrote:
> This is so small it is almost not worth mentioning, but I thought you
might like
> it:

> return ui.create_controls(
> *(ui.MenuButton(caption, callback) for caption, callback in
actions))

> Or if we want to go full-on functional:

> return ui.create_controls(*map(ui.MenuButton, actions))

Done.

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/ui.py
File quickstart/cli/ui.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/ui.py#newcode49
quickstart/cli/ui.py:49: ('optional status', 'light magenta', 'light
gray'),
On 2014/01/02 15:05:32, benji1 wrote:
> Unfortunately the non-256 color terminal colors are not standardized
so I
> suggest testing on multiple terminals (I can test on xterm, gnome
terminal, and
> terminator if you want).

My understanding is that these color combinations can be safely used
cross-terminal because the colors are handled by Urwid itself:
http://excess.org/urwid/docs/reference/constants.html?highlight=colors#foreground-and-background-colors
But I can be wrong, and maybe it is worth testing it.

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py#newcode168
quickstart/cli/views.py:168: for position, env_data in
enumerate(environments):
On 2014/01/02 15:19:28, gary.poster wrote:
> Just as a way to make the main function smaller, what would you think
of
> breaking this block out into its own function?

> ...eh, nevermind. I tried to do it and you need default_found and
> focus_position and errors_found, so it doesn't seem like there's much
point.

> The function is very clear and linear but I was hoping to make it
shorter by
> making some nice internal verbs. Maybe you have another idea? Could
be for
> later, or never. :-)

Not sure, a quick solution might be splitting the f...

Read more...

Revision history for this message
Francesco Banconi (frankban) wrote :

Now working on the QA comments.
In the meanwhile, thank you for the great reviews!

https://codereview.appspot.com/44750044/

Revision history for this message
Gary Poster (gary) wrote :

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/44750044/diff/1/quickstart/cli/views.py#newcode230
quickstart/cli/views.py:230: def use(env_data):
On 2014/01/02 17:51:04, frankban wrote:
> On 2014/01/02 15:19:28, gary.poster wrote:
> > I wonder if the env_detail function would look less forbidding at
first glance
> > if these internal functions were defined outside (_use,
_set_default, etc.)
> and
> > then hooked up as partials. I think the verbs are clear enough that
it would
> > read well.
> >
> > A counter-argument is that you are defining everything locally, so
you can
> read
> > what is going on inline, and it similar to what people are
accustomed to doing
> > with OO. If env_detail were in its own file as is, would it look
super easy,
> > because it would look similar to a class with methods? I don't
know.
> >
> > What do you think?

> This is a really good question. I like the possibility to use closures
so that
> you can share the scope and group code logic, but, as you mentioned,
this can be
> surprising at first sight. If you add to this the good point Benji
made below
> (it is easier to directly unit test separate functions), then we have
two good
> arguments to make this change. Since it requires a bit of code
reorganization
> I'd be inclined to tackle this separately, how does it sound?

Sounds great to me. Thank you.

https://codereview.appspot.com/44750044/

56. By Francesco Banconi

Footer fixes.

57. By Francesco Banconi

Footer fixes.

58. By Francesco Banconi

Changes as per QA.

Revision history for this message
Francesco Banconi (frankban) wrote :
Revision history for this message
Francesco Banconi (frankban) wrote :
Download full text (4.8 KiB)

On 2014/01/02 14:24:57, gary.poster wrote:
> QA comments, pre-review:

> - Looking very cool.
> - I like the fact that I can right click on links to launch them.
That may well
> be a terminal feature rather than a quickstart or urwid feature, but
it all fits
> together nicely.

I agree, that's cool.

> - Trivial, but I continue to be dissatisfied with the footer
formatting. I
> don't like the fact that the text in the legend line wraps. For
instance, when
> creating, I want "field errors" to be on the same line as the dot it
describes.
> Possible resolutions include the following: (a) we don't show
"juju-quickstart
> 0.5.0" anywhere; (b) we show it in the right of the header; or (c) we
show it in
> its own line in the footer.

Removed the quickstart brand and put the notifications widget in its own
line
(above the footer). This way I see the status in a single line even when
the terminal size is 80x24.

> - It would be nice if you could tab between fields. I assume this is
an Urwid
> limitation, in which case never mind. Maybe we could clarify in the
footer that
> arrow keys move between fields?

I'd also like tab navigation to be implemented as part of the UX
improvement
card(s). I think it should not be too difficult to implement that in
Urwid.
In the meanwhile, added navigation help to the status as you suggested.

> - It was not clear to me that clicking on "automatically generate"
would
> generate the admin secret. Could we maybe change that text to "Click
here to
> automatically generate this value"?

Good idea, done.

> - For the default series, you say that you may leave the series empty.
  It would
> be nice if you explained the semantics of that in the help text. Am I
right
> that the semantics are that the series of the host computer is used?
> - Should we default the default series value to precise?

You are right. While we usually let juju itself handle the default
values
where not set, for the default series quickstart should force an
explicit
"precise" when the value is not specified. This means the default series
field must overwrite the normalize method, and that should be easy
enough.
I'll do this in another card, perhaps the same as the one below
(choices).

> - Maybe a bad idea: we could make the "precise," "quantal," and other
default
> series text clickable. When you click on them, they fill in the field
with the
> given value. Same could be done with ec2 region.
> - Maybe a better idea: Does Urwid support the idea of a combo box,
somehow?
> Would be nice. :-)

AFAIK Urwid does not have a combo widget out of the box, but we can try
to
implement it on top of the other base widgets. The first solution sounds
good
to me anyway, and as you noticed I already have an XXX for it, and I
will
create a card as well.

> - I wish the two "create" options in the main menu looked more
different. Any
> bright UX ideas? :-) Right now the whitespace separating the
environments from
> the creation options don't clarify their difference sufficiently for
my eye.

Updated the index view so that the environment creation options are more
separated. Now there is a text indicating how to use the creation links.
Does it look better?

> ...

Read more...

Revision history for this message
Gary Poster (gary) wrote :
Download full text (5.3 KiB)

On 2014/01/03 11:42:10, frankban wrote:
> On 2014/01/02 14:24:57, gary.poster wrote:
> > QA comments, pre-review:
> >
> > - Looking very cool.
> > - I like the fact that I can right click on links to launch them.
That may
> well
> > be a terminal feature rather than a quickstart or urwid feature, but
it all
> fits
> > together nicely.

> I agree, that's cool.

> > - Trivial, but I continue to be dissatisfied with the footer
formatting. I
> > don't like the fact that the text in the legend line wraps. For
instance,
> when
> > creating, I want "field errors" to be on the same line as the dot it
> describes.
> > Possible resolutions include the following: (a) we don't show
"juju-quickstart
> > 0.5.0" anywhere; (b) we show it in the right of the header; or (c)
we show it
> in
> > its own line in the footer.

> Removed the quickstart brand and put the notifications widget in its
own line
> (above the footer). This way I see the status in a single line even
when
> the terminal size is 80x24.

Much better, thanks!

> > - It would be nice if you could tab between fields. I assume this
is an Urwid
> > limitation, in which case never mind. Maybe we could clarify in the
footer
> that
> > arrow keys move between fields?

> I'd also like tab navigation to be implemented as part of the UX
improvement
> card(s). I think it should not be too difficult to implement that in
Urwid.
> In the meanwhile, added navigation help to the status as you
suggested.

Yes, looks good.

> > - It was not clear to me that clicking on "automatically generate"
would
> > generate the admin secret. Could we maybe change that text to
"Click here to
> > automatically generate this value"?

> Good idea, done.

> > - For the default series, you say that you may leave the series
empty. It
> would
> > be nice if you explained the semantics of that in the help text. Am
I right
> > that the semantics are that the series of the host computer is used?
> > - Should we default the default series value to precise?

> You are right. While we usually let juju itself handle the default
values
> where not set, for the default series quickstart should force an
explicit
> "precise" when the value is not specified. This means the default
series
> field must overwrite the normalize method, and that should be easy
enough.
> I'll do this in another card, perhaps the same as the one below
(choices).

Cool

> > - Maybe a bad idea: we could make the "precise," "quantal," and
other default
> > series text clickable. When you click on them, they fill in the
field with
> the
> > given value. Same could be done with ec2 region.
> > - Maybe a better idea: Does Urwid support the idea of a combo box,
somehow?
> > Would be nice. :-)

> AFAIK Urwid does not have a combo widget out of the box, but we can
try to
> implement it on top of the other base widgets. The first solution
sounds good
> to me anyway, and as you noticed I already have an XXX for it, and I
will
> create a card as well.

Great. I think the links will be fine (instead of trying to implement a
combobox) as long as our text clearly indicates the functionality (e.g.
"Click one to autofill the field with these standard options: precise,
quant...

Read more...

Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

Implement the environment create/edit view.

Add a cli/forms.py module including helpers
to create and handle Urwid forms.

Allow for creating a new environment from the
env_index view.

Implement the env_edit view, used for creating
or updating environments.

Fix a corner case in envs.set_env_data: delete
the env_db default key only if the environments
has been modified.

Tests: `make check`.

QA: start the demo app
(`make` and `./cli-app-demo.py).
Use it to edit existing environments and
to create new ones (ec2 and local).
Check form errors are correctly handled.
Exit the demo app with either ^X or
selecting an environment to use. The
demo app should show changes on exit,
and notify whether a new db has been saved
and/or an environment has been selected.

R=gary.poster, benji1
CC=
https://codereview.appspot.com/44750044

https://codereview.appspot.com/44750044/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'quickstart/app.py'
2--- quickstart/app.py 2013-12-19 03:29:39 +0000
3+++ quickstart/app.py 2014-01-03 11:38:58 +0000
4@@ -232,8 +232,10 @@
5
6 Do not bootstrap the environment if already bootstrapped.
7
8- Return True without errors if the environment is already bootstrapped.
9- Return False otherwise. Only return when the bootstrap node is ready.
10+ Return a tuple (already_bootstrapped, series) in which:
11+ - already_bootstrapped indicates whether the environment was already
12+ bootstrapped;
13+ - series is the bootstrap node Ubuntu series.
14
15 The is_local argument indicates whether the environment is configured to
16 use the local provider. If so, sudo privileges are requested in order to
17
18=== modified file 'quickstart/cli/base.py'
19--- quickstart/cli/base.py 2013-12-19 11:10:46 +0000
20+++ quickstart/cli/base.py 2014-01-03 11:38:58 +0000
21@@ -29,7 +29,6 @@
22
23 import urwid
24
25-from quickstart import get_version
26 from quickstart.cli import ui
27
28
29@@ -129,13 +128,13 @@
30
31 # Set up the application footer.
32 # The CTRL-x shortcut is automatically set up by views.show().
33- brand_message = 'juju-quickstart v{} - ^X exit '.format(get_version())
34- brand = urwid.Text(brand_message)
35+ message = urwid.Text('', align='center')
36+ base_status = urwid.Text('^X exit ')
37 status = urwid.Text('')
38- message = urwid.Text('', align='right')
39+ status_columns = urwid.Columns([('pack', base_status), status])
40 footer_line = urwid.Divider('\N{UPPER ONE EIGHTH BLOCK}')
41- status_columns = urwid.Columns([('pack', brand), status, message])
42 footer = urwid.Pile([
43+ message,
44 urwid.Divider(),
45 urwid.AttrMap(ui.padding(status_columns), 'footer'),
46 urwid.AttrMap(footer_line, 'line footer'),
47
48=== added file 'quickstart/cli/forms.py'
49--- quickstart/cli/forms.py 1970-01-01 00:00:00 +0000
50+++ quickstart/cli/forms.py 2014-01-03 11:38:58 +0000
51@@ -0,0 +1,215 @@
52+# This file is part of the Juju Quickstart Plugin, which lets users set up a
53+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
54+# Copyright (C) 2014 Canonical Ltd.
55+#
56+# This program is free software: you can redistribute it and/or modify it under
57+# the terms of the GNU Affero General Public License version 3, as published by
58+# the Free Software Foundation.
59+#
60+# This program is distributed in the hope that it will be useful, but WITHOUT
61+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
62+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
63+# Affero General Public License for more details.
64+#
65+# You should have received a copy of the GNU Affero General Public License
66+# along with this program. If not, see <http://www.gnu.org/licenses/>.
67+
68+"""Juju Quickstart CLI forms management.
69+
70+This module contains a collection of functions which help creating and
71+manipulating forms in Urwid.
72+"""
73+
74+from __future__ import unicode_literals
75+
76+import functools
77+
78+import urwid
79+
80+from quickstart.cli import ui
81+
82+
83+# Define the value used in boolean widgets to specify they allow mixed state.
84+MIXED = 'mixed'
85+
86+
87+def _generate(generate_callable, edit_widget):
88+ """Update the widget contents using the given generate_callable.
89+
90+ The passed callable function takes no arguments and returns a string.
91+ """
92+ edit_widget.set_edit_text(generate_callable())
93+
94+
95+def _create_generate_widget(generate_callable, edit_widget):
96+ """Create and return a button widget used to generate values for a field.
97+
98+ Receives the generate callable and the target edit widget.
99+ """
100+ generate_callback = ui.thunk(_generate, generate_callable, edit_widget)
101+ generate_button = ui.MenuButton(
102+ ('highlight', 'Click here to automatically generate'),
103+ generate_callback)
104+ return urwid.Columns([
105+ ('pack', generate_button),
106+ urwid.Text(' this value'),
107+ ])
108+
109+
110+def create_string_widget(field, value, error):
111+ """Create a string widget and return a tuple (widget, value_getter).
112+
113+ Receives a Field instance (see quickstart.models.fields), the field value,
114+ and an error string (or None if the field has no errors).
115+
116+ In the returned tuple, widget is a Urwid widget suitable for editing string
117+ values, and value_getter is a callable returning the value currently stored
118+ in the widget. The value_getter callable must be called without arguments.
119+ """
120+ if value is None:
121+ # Unset values are converted to empty strings.
122+ value = ''
123+ elif not isinstance(value, unicode):
124+ # We do not expect byte strings, and all other values are converted to
125+ # unicode strings.
126+ value = unicode(value)
127+ caption_class = 'highlight' if field.required else 'optional'
128+ caption = []
129+ widgets = []
130+ if error:
131+ caption.append(('error', '\N{BULLET} '))
132+ # Display the error message above the edit widget.
133+ widgets.append(urwid.Text(('error', error)))
134+ caption.append((caption_class, '{}: '.format(field.label)))
135+ edit_widget = urwid.Edit(edit_text=value)
136+ widget = urwid.Columns([('pack', urwid.Text(caption)), edit_widget])
137+ if field.readonly:
138+ # Disable the widget if the field is not editable.
139+ widgets.append(urwid.WidgetDisable(widget))
140+ else:
141+ widgets.append(urwid.AttrMap(widget, 'edit'))
142+ if field.help:
143+ # Display the field help message below the edit widget.
144+ widgets.append(urwid.Text(field.help))
145+ if not field.readonly:
146+ # Can the value be automatically generated?
147+ generate_callable = getattr(field, 'generate', None)
148+ if generate_callable is not None:
149+ widgets.append(
150+ _create_generate_widget(generate_callable, edit_widget))
151+ # If the value must be in a range of choices, display the possible choices
152+ # as part of the help message.
153+ choices = getattr(field, 'choices', None)
154+ if choices is not None:
155+ # XXX frankban 2014-01-02: make the choices clickable and update the
156+ # edit widget when a choice is clicked.
157+ text = 'possible values are {}'.format(', '.join(choices))
158+ if not field.required:
159+ text = '{}, but this field can also be left empty'.format(text)
160+ widgets.append(urwid.Text(text))
161+ if field.default is not None:
162+ widgets.append(
163+ urwid.Text('default if not set: {}'.format(field.default)))
164+ widgets.append(urwid.Divider())
165+ return urwid.Pile(widgets), edit_widget.get_edit_text
166+
167+
168+def create_bool_widget(field, value, error):
169+ """Create a boolean widget and return a tuple (widget, value_getter).
170+
171+ Receives a Field instance (see quickstart.models.fields), the field value,
172+ and an error string (or None if the field has no errors).
173+
174+ In the returned tuple, widget is a Urwid widget suitable for editing
175+ boolean values (a checkbox), and value_getter is a callable returning the
176+ value currently stored in the widget. The value_getter callable receives no
177+ arguments.
178+ """
179+ if value is None:
180+ # Unset values are converted to a more convenient value for the
181+ # checkbox (a boolean or a mixed state if allowed by the field).
182+ value = MIXED if field.allow_mixed else bool(field.default)
183+ label = ('highlight', field.label)
184+ widget = urwid.CheckBox(label, state=value, has_mixed=field.allow_mixed)
185+ widgets = []
186+ if error:
187+ # Display the error message above the checkbox.
188+ widgets.append(urwid.Text(('error', error)))
189+ if field.readonly:
190+ # Disable the widget if the field is not editable.
191+ widgets.append(urwid.WidgetDisable(widget))
192+ else:
193+ widgets.append(widget)
194+ if field.help:
195+ # Display the field help message below the checkbox.
196+ widgets.append(urwid.Text(field.help))
197+ widgets.append(urwid.Divider())
198+
199+ def get_state():
200+ # Reconvert mixed value to None value.
201+ state = widget.get_state()
202+ return None if state == MIXED else state
203+
204+ return urwid.Pile(widgets), get_state
205+
206+
207+def create_form(field_value_pairs, errors):
208+ """Create and return the form widgets for each field/value pair.
209+
210+ Return a tuple (widgets, values_getter) in which:
211+ - widgets is a list if Urwid objects that can be used to build view
212+ contents (e.g. by wrapping them in a urwid.ListBox);
213+ - values_getter is a function returning a dictionary mapping field
214+ names to values. By calling the values_getter function it is always
215+ possible to retrieve the current new field values, even if they have
216+ been changed by the user.
217+
218+ The field_value_pairs argument is a list of (field, value) tuples where
219+ field is a Field instance (see quickstart.models.fields) and value is
220+ the corresponding field's value.
221+
222+ The errors argument is a dictionary mapping field names to error messages.
223+ Passing an empty dictionary means the form has no errors.
224+ """
225+ form = {}
226+ widgets = []
227+ if errors:
228+ # Inform the user that the form has errors that need to be fixed.
229+ num_errors = len(errors)
230+ msg = 'error' if num_errors == 1 else '{} errors'.format(num_errors)
231+ widgets.extend([
232+ urwid.Text(('error', 'please correct the {} below'.format(msg))),
233+ urwid.Divider(),
234+ ])
235+ # Build a widget and populate the form for each field/value pair.
236+ for field, value in field_value_pairs:
237+ error = errors.get(field.name)
238+ if field.field_type == 'bool':
239+ # Boolean values are represented as checkboxes.
240+ widget_factory = create_bool_widget
241+ else:
242+ # All the other field types are displayed as strings.
243+ widget_factory = create_string_widget
244+ widget, value_getter = widget_factory(field, value, error)
245+ widgets.append(widget)
246+ form[field.name] = value_getter
247+ return widgets, functools.partial(_get_data, form)
248+
249+
250+def _get_data(form):
251+ """Return a dictionary mapping the given form field names to values.
252+
253+ This is done just calling all the value getters in the form.
254+ """
255+ return dict((key, value_getter()) for key, value_getter in form.items())
256+
257+
258+def create_actions(actions):
259+ """Return the control widgets based on the given actions.
260+
261+ The actions arguments is as a sequence of (caption, callback) tuples. Those
262+ pairs are used to generate the clickable controls (MenuButton instances)
263+ used to manipulate the form.
264+ """
265+ return ui.create_controls(
266+ *(ui.MenuButton(caption, callback) for caption, callback in actions))
267
268=== modified file 'quickstart/cli/ui.py'
269--- quickstart/cli/ui.py 2013-12-20 10:30:37 +0000
270+++ quickstart/cli/ui.py 2014-01-03 11:38:58 +0000
271@@ -45,6 +45,8 @@
272 ('highlight', 'white', 'black'),
273 ('line header', 'dark gray', 'dark magenta'),
274 ('line footer', 'light gray', 'light gray'),
275+ ('optional', 'light magenta', 'black'),
276+ ('optional status', 'light magenta', 'light gray'),
277 ('selected', 'white', 'dark blue'),
278 ]
279 # Map attributes to new attributes to apply when the widget is selected.
280@@ -52,6 +54,7 @@
281 None: 'selected',
282 'control alert': 'error selected',
283 'error': 'error selected',
284+ 'highlight': 'selected',
285 }
286 # Define a default padding for the Urwid application.
287 padding = functools.partial(urwid.Padding, left=2, right=2)
288
289=== modified file 'quickstart/cli/views.py'
290--- quickstart/cli/views.py 2013-12-19 17:40:36 +0000
291+++ quickstart/cli/views.py 2014-01-03 11:38:58 +0000
292@@ -104,6 +104,7 @@
293
294 from quickstart.cli import (
295 base,
296+ forms,
297 ui,
298 )
299 from quickstart.models import envs
300@@ -132,6 +133,8 @@
301 """Show the Juju environments list.
302
303 The env_detail view is displayed when the user clicks on an environment.
304+ From here it is also possible to switch to the edit view in order to create
305+ a new environment.
306
307 Receives:
308 - env_type_db: the environments meta information;
309@@ -145,15 +148,16 @@
310 app.set_return_value_on_exit((env_db, None))
311 detail_view = functools.partial(
312 env_detail, app, env_type_db, env_db, save_callable)
313+ edit_view = functools.partial(
314+ env_edit, app, env_type_db, env_db, save_callable)
315 # Alphabetically sort the existing environments.
316 environments = sorted([
317 envs.get_env_data(env_db, env_name)
318 for env_name in env_db['environments']
319 ], key=operator.itemgetter('name'))
320 if environments:
321- title = 'Select the Juju environment you want to use'
322+ title = 'Select an existing Juju environment or create a new one'
323 else:
324- # XXX frankban 2013-12-18: implement the env creation view.
325 title = 'No Juju environments already set up: please create one'
326 app.set_title(title)
327 app.set_status('')
328@@ -178,10 +182,20 @@
329 env_short_description = envs.get_env_short_description(env_data)
330 text = [bullet, ' {}'.format(env_short_description)]
331 widgets.append(ui.MenuButton(text, ui.thunk(detail_view, env_data)))
332- widgets.append(urwid.Divider())
333- contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
334- if focus_position is not None:
335- contents.set_focus(focus_position)
336+ # Add the buttons used to create new environments.
337+ widgets.extend([
338+ urwid.Divider(),
339+ urwid.Text((
340+ 'highlight', 'Use the links below to create new environments')),
341+ urwid.Divider(),
342+ ])
343+ widgets.extend([
344+ ui.MenuButton(
345+ ['\N{BULLET} new ', ('highlight', env_type), ' environment'],
346+ ui.thunk(edit_view, {'type': env_type})
347+ )
348+ for env_type in envs.get_supported_env_types(env_type_db)
349+ ])
350 # Set up the application status messages.
351 status = []
352 if default_found:
353@@ -190,6 +204,10 @@
354 status.extend([('error status', ' \N{BULLET}'), ' has errors '])
355 if status:
356 app.set_status(status)
357+ # Set up the application contents.
358+ contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
359+ if focus_position is not None:
360+ contents.set_focus(focus_position)
361 app.set_contents(contents)
362
363
364@@ -202,7 +220,7 @@
365 Receives:
366 - env_type_db: the environments meta information;
367 - env_db: the environments database;
368- - save_callable: a function called to save a new environment database.
369+ - save_callable: a function called to save a new environment database;
370 - env_data: the environment data.
371 """
372 env_db = copy.deepcopy(env_db)
373@@ -212,6 +230,8 @@
374 app.set_return_value_on_exit((env_db, None))
375 index_view = functools.partial(
376 env_index, app, env_type_db, env_db, save_callable)
377+ edit_view = functools.partial(
378+ env_edit, app, env_type_db, env_db, save_callable, env_data)
379
380 def use(env_data):
381 # Quit the interactive session returning the (possibly modified)
382@@ -231,8 +251,10 @@
383 def remove(env_data):
384 # The environment deletion is confirmed: remove the environment from
385 # the database, save the new env_db and return to the index view.
386- envs.remove_env(env_db, env_data['name'])
387+ env_name = env_data['name']
388+ envs.remove_env(env_db, env_name)
389 save_callable(env_db)
390+ app.set_message('{} successfully removed'.format(env_name))
391 index_view()
392
393 def confirm_removal(env_data):
394@@ -272,10 +294,113 @@
395 controls.append(
396 ui.MenuButton('set default', ui.thunk(set_default, env_data)))
397 controls.extend([
398- # XXX frankban 2013-12-18: implement the env modification view.
399+ ui.MenuButton('edit', ui.thunk(edit_view)),
400 ui.MenuButton(
401 ('control alert', 'remove'), ui.thunk(confirm_removal, env_data)),
402 ])
403 widgets.append(ui.create_controls(*controls))
404 listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
405 app.set_contents(listbox)
406+
407+
408+def env_edit(app, env_type_db, env_db, save_callable, env_data):
409+ """Create or modify a Juju environment.
410+
411+ This view displays an edit form allowing for environment
412+ creation/modification. Saving the form redirects to the environment detail
413+ view if the values are valid.
414+
415+ Receives:
416+ - env_type_db: the environments meta information;
417+ - env_db: the environments database;
418+ - save_callable: a function called to save a new environment database;
419+ - env_data: the environment data.
420+
421+ The last value (env_data) indicates whether this view is used to create a
422+ new environment or to change an existing one. In the former case, env_data
423+ only includes the "type" key. If instead the environment already exists,
424+ env_data includes the "name" key and all the other environment info.
425+ """
426+ env_db = copy.deepcopy(env_db)
427+ # All the environment views return a tuple (new_env_db, env_data).
428+ # Set the env_data to None in the case the user quits the application
429+ # without selecting an environment to use.
430+ app.set_return_value_on_exit((env_db, None))
431+ env_metadata = envs.get_env_metadata(env_type_db, env_data)
432+ index_view = functools.partial(
433+ env_index, app, env_type_db, env_db, save_callable)
434+ detail_view = functools.partial(
435+ env_detail, app, env_type_db, env_db, save_callable)
436+ if 'name' in env_data:
437+ exists = True
438+ title = 'Edit the {} environment'
439+ # Retrieve all the errors for the existing environment.
440+ initial_errors = envs.validate(env_metadata, env_data)
441+ else:
442+ exists = False
443+ title = 'Create a new {} environment'
444+ # The environment does not exist: avoid bothering the user with errors
445+ # before the form is submitted.
446+ initial_errors = {}
447+ app.set_title(title.format(env_data['type']))
448+ app.set_status([
449+ ' \N{UPWARDS ARROW LEFTWARDS OF DOWNWARDS ARROW} navigate ',
450+ ('optional status', ' \N{LOWER SEVEN EIGHTHS BLOCK}'),
451+ ' optional field ',
452+ ('error status', ' \N{BULLET}'),
453+ ' field errors ',
454+ ])
455+
456+ def save(env_data, get_new_env_data):
457+ # Create a new environment or save changes for an existing one.
458+ # The new values are saved only if the new env_data is valid, in which
459+ # case also redirect to the environment detail view.
460+ new_env_data = get_new_env_data()
461+ # Validate the new env_data.
462+ errors = envs.validate(env_metadata, new_env_data)
463+ new_name = new_env_data['name']
464+ initial_name = env_data.get('name')
465+ if (new_name != initial_name) and new_name in env_db['environments']:
466+ errors['name'] = 'an environment with this name already exists'
467+ # If errors are found, re-render the form passing the errors. This way
468+ # the errors are displayed as part of the form and the user is given
469+ # the opportunity to fix the invalid values.
470+ if errors:
471+ return render_form(new_env_data, errors)
472+ # Without errors, normalize the new values, update the env_db and save
473+ # the resulting environments database.
474+ env_data = envs.normalize(env_metadata, new_env_data)
475+ envs.set_env_data(env_db, initial_name, env_data)
476+ save_callable(env_db)
477+ verb = 'modified' if exists else 'created'
478+ app.set_message('{} successfully {}'.format(new_name, verb))
479+ return detail_view(env_data)
480+
481+ def cancel(env_data):
482+ # Dismiss any changes and return to the index or detail view.
483+ return detail_view(env_data) if exists else index_view()
484+
485+ def render_form(data, errors):
486+ # Render the environment edit form.
487+ widgets = [
488+ urwid.Text(env_metadata['description']),
489+ urwid.Divider(),
490+ ]
491+ field_value_pairs = envs.map_fields_to_env_data(env_metadata, data)
492+ # Retrieve the form widgets and the data getter function. The latter
493+ # can be used to retrieve the field name/value pairs included in the
494+ # displayed form, including user's changes (see quickstart.cli.forms).
495+ form_widgets, get_new_env_data = forms.create_form(
496+ field_value_pairs, errors)
497+ widgets.extend(form_widgets)
498+ actions = (
499+ ('save', ui.thunk(save, env_data, get_new_env_data)),
500+ ('cancel', ui.thunk(cancel, env_data)),
501+ ('restore', ui.thunk(render_form, env_data, initial_errors)),
502+ )
503+ widgets.append(forms.create_actions(actions))
504+ contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
505+ app.set_contents(contents)
506+
507+ # Render the initial form.
508+ render_form(env_data, initial_errors)
509
510=== modified file 'quickstart/manage.py'
511--- quickstart/manage.py 2013-12-06 18:28:40 +0000
512+++ quickstart/manage.py 2014-01-03 11:38:58 +0000
513@@ -303,10 +303,11 @@
514 env = app.connect(api_url, options.admin_secret)
515
516 # It is not possible to deploy on the bootstrap node if we are using the
517- # local provider.
518- to_new_machine = (is_local or
519- bsn_series != settings.JUJU_GUI_PREFERRED_SERIES)
520- machine = None if to_new_machine else '0'
521+ # local provider, or if the bootstrap node series is not compatible with
522+ # the Juju GUI charm.
523+ machine = '0'
524+ if is_local or (bsn_series != settings.JUJU_GUI_PREFERRED_SERIES):
525+ machine = None
526 unit_name = app.deploy_gui(
527 env, settings.JUJU_GUI_SERVICE_NAME, machine,
528 charm_url=options.charm_url, check_preexisting=already_bootstrapped)
529
530=== modified file 'quickstart/models/envs.py'
531--- quickstart/models/envs.py 2013-12-19 16:48:38 +0000
532+++ quickstart/models/envs.py 2014-01-03 11:38:58 +0000
533@@ -292,7 +292,7 @@
534 environments[new_name] = new_env_data
535 if is_default:
536 env_db['default'] = new_name
537- elif env_db.get('default') == env_name:
538+ elif (env_db.get('default') == env_name) and (env_name is not None):
539 del env_db['default']
540
541
542@@ -363,10 +363,21 @@
543 default_series_field,
544 fields.PasswordField(
545 'access-key', label='access key', required=True,
546- help='the access key to use to authenticate to AWS'),
547+ help='The access key to use to authenticate to AWS. '
548+ 'You can retrieve these values easily from your AWS '
549+ 'Management Console (http://console.aws.amazon.com). '
550+ 'Click on your name in the top-right and then the '
551+ '"Security Credentials" link from the drop down '
552+ 'menu. Under the "Access Keys" heading click the '
553+ '"Create New Root Key" button. You will be prompted '
554+ 'to "Download Key File" which by default is named '
555+ 'rootkey.csv. Open this file to get the access-key '
556+ 'and secret-key for the environments.yaml '
557+ 'configuration file.'),
558 fields.PasswordField(
559 'secret-key', label='secret key', required=True,
560- help='the secret key to use to authenticate to AWS'),
561+ help='The secret key to use to authenticate to AWS. '
562+ 'See the access key help above.'),
563 fields.AutoGeneratedStringField(
564 'control-bucket', label='control bucket', required=True,
565 help='the globally unique S3 bucket name'),
566
567=== modified file 'quickstart/tests/cli/test_base.py'
568--- quickstart/tests/cli/test_base.py 2013-12-19 10:59:57 +0000
569+++ quickstart/tests/cli/test_base.py 2014-01-03 11:38:58 +0000
570@@ -82,25 +82,25 @@
571 # The contents widget is the body's original widget.
572 return body.original_widget
573
574- def _get_footer_columns(self, loop):
575+ def _get_footer(self, loop):
576 # The frame is the main overlay's top widget.
577 frame = loop.widget.top_w
578 # Retrieve the footer.
579- footer = frame.contents['footer'][0]
580- # Return the columns widget.
581- return footer.contents[1][0].base_widget
582+ return frame.contents['footer'][0]
583
584 def get_status_widget(self, loop):
585 """Return the status widget given the application main loop."""
586- columns = self._get_footer_columns(loop)
587- # The status widget is the second one (brand, status, message).
588+ footer = self._get_footer(loop)
589+ # The status columns is the third widget (message, divider, status).
590+ columns = footer.contents[2][0].base_widget
591+ # The status widget is the second one (base status, status).
592 return columns.contents[1][0]
593
594 def get_message_widget(self, loop):
595 """Return the message widget given the application main loop."""
596- columns = self._get_footer_columns(loop)
597- # The message widget is the third one (brand, status, message).
598- return columns.contents[2][0]
599+ footer = self._get_footer(loop)
600+ # The message widget is the first one (message, divider, status).
601+ return footer.contents[0][0].base_widget
602
603 def test_loop(self):
604 # The returned loop is an instance of the base customized loop.
605
606=== added file 'quickstart/tests/cli/test_forms.py'
607--- quickstart/tests/cli/test_forms.py 1970-01-01 00:00:00 +0000
608+++ quickstart/tests/cli/test_forms.py 2014-01-03 11:38:58 +0000
609@@ -0,0 +1,345 @@
610+# This file is part of the Juju Quickstart Plugin, which lets users set up a
611+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
612+# Copyright (C) 2014 Canonical Ltd.
613+#
614+# This program is free software: you can redistribute it and/or modify it under
615+# the terms of the GNU Affero General Public License version 3, as published by
616+# the Free Software Foundation.
617+#
618+# This program is distributed in the hope that it will be useful, but WITHOUT
619+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
620+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
621+# Affero General Public License for more details.
622+#
623+# You should have received a copy of the GNU Affero General Public License
624+# along with this program. If not, see <http://www.gnu.org/licenses/>.
625+
626+"""Tests for the Juju Quickstart CLI forms management."""
627+
628+from __future__ import unicode_literals
629+
630+import unittest
631+
632+import mock
633+import urwid
634+
635+from quickstart.cli import (
636+ forms,
637+ ui,
638+)
639+from quickstart.models import fields
640+from quickstart.tests.cli import helpers as cli_helpers
641+
642+
643+class TestGenerate(unittest.TestCase):
644+
645+ def test_generation(self):
646+ # The text of the given widget is set to the return value of the given
647+ # callable.
648+ generate_callable = lambda: 'generated'
649+ mock_edit_widget = mock.Mock()
650+ forms._generate(generate_callable, mock_edit_widget)
651+ mock_edit_widget.set_edit_text.assert_called_once_with('generated')
652+
653+
654+class TestCreateGenerateWidget(unittest.TestCase):
655+
656+ def test_widget_creation(self):
657+ # The generate widget is properly created and returned.
658+ generate_callable = lambda: 'generated'
659+ mock_edit_widget = mock.Mock()
660+ widget = forms._create_generate_widget(
661+ generate_callable, mock_edit_widget)
662+ # The generate button is the first widget in the urwid.Columns.
663+ button = widget.contents[0][0]
664+ cli_helpers.emit(button)
665+ mock_edit_widget.set_edit_text.assert_called_once_with('generated')
666+
667+
668+class TestCreateStringWidget(unittest.TestCase):
669+
670+ def inspect_widget(self, widget, field):
671+ """Return a list of sub-widgets composing the given string widget.
672+
673+ The sub-widgets are:
674+ - the wrapper edit widget;
675+ - the base edit widget;
676+ - the caption text widget;
677+ - the help widget (or None if not present);
678+ - the error text widget (or None if not present);
679+ - the generate text widget (or None if not present);
680+ - the choices text widget (or None if not present);
681+ - the default text widget (or None if not present).
682+ """
683+ help = error = generate = choices = default = None
684+ # Retrieve the Pile contents ignoring the last Divider widget.
685+ contents = list(widget.contents)[:-1]
686+ first_widget = contents.pop(0)[0]
687+ if isinstance(first_widget, urwid.Text):
688+ # This is the error message.
689+ error = first_widget
690+ wrapper = contents.pop(0)[0]
691+ else:
692+ # The widget has no errors.
693+ wrapper = first_widget
694+ caption_attrs, edit_attrs = wrapper.base_widget.contents
695+ if field.help:
696+ help = contents.pop(0)[0]
697+ if hasattr(field, 'generate'):
698+ generate = contents.pop(0)[0]
699+ if hasattr(field, 'choices'):
700+ choices = contents.pop(0)[0]
701+ if field.default is not None:
702+ default = contents.pop(0)[0]
703+ return (
704+ wrapper, edit_attrs[0], caption_attrs[0],
705+ help, error, generate, choices, default,
706+ )
707+
708+ def test_widget_structure(self):
709+ # The widget includes all the information about a field.
710+ field = fields.StringField(
711+ 'first-name', label='first name', help='your first name',
712+ default='Jean')
713+ widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')
714+ wrapper, edit, caption, help, error, generate, choices, default = (
715+ self.inspect_widget(widget, field))
716+ # Since the field is not read-only, the widget is properly enabled.
717+ self.assertNotIsInstance(wrapper, urwid.WidgetDisable)
718+ # The edit widget is set to the given value.
719+ self.assertEqual('Luc', edit.get_edit_text())
720+ # The caption and help are properly set.
721+ self.assertEqual('\N{BULLET} first name: ', caption.text)
722+ self.assertEqual('your first name', help.text)
723+ # The error is displayed.
724+ self.assertEqual('invalid name', error.text)
725+ # The field is not able to generate a value, and there are no choices.
726+ self.assertIsNone(generate)
727+ self.assertIsNone(choices)
728+ # The default value is properly displayed.
729+ self.assertEqual('default if not set: Jean', default.text)
730+
731+ def test_value_getter(self):
732+ # The returned value getter function returns the current widget value.
733+ field = fields.StringField('first-name')
734+ widget, value_getter = forms.create_string_widget(field, 'Luc', None)
735+ edit = self.inspect_widget(widget, field)[1]
736+ self.assertEqual('Luc', value_getter())
737+ # The value getter is lazy and always returns the current value.
738+ edit.set_edit_text('Jean-Luc')
739+ self.assertEqual('Jean-Luc', value_getter())
740+
741+ def test_unset_value(self):
742+ # The initial value is set to an empty string if the field is unset.
743+ field = fields.StringField('first-name')
744+ _, value_getter = forms.create_string_widget(field, None, None)
745+ self.assertEqual('', value_getter())
746+
747+ def test_value_not_a_string(self):
748+ # Non-string values are converted to unicode strings.
749+ field = fields.StringField('first-name')
750+ _, value_getter = forms.create_string_widget(field, 42, None)
751+ self.assertEqual('42', value_getter())
752+
753+ def test_readonly_field(self):
754+ # The widget is disabled if the field is read-only.
755+ field = fields.StringField('first-name', readonly=True)
756+ widget, _ = forms.create_string_widget(field, 'Jean-Luc', None)
757+ wrapper = self.inspect_widget(widget, field)[0]
758+ self.assertIsInstance(wrapper, urwid.WidgetDisable)
759+
760+ def test_autogenerated_field(self):
761+ # The widgets allows for automatically generating field values.
762+ field = fields.AutoGeneratedStringField('password')
763+ with mock.patch.object(field, 'generate', lambda: 'auto-generated!'):
764+ widget, value_getter = forms.create_string_widget(field, '', None)
765+ generate = self.inspect_widget(widget, field)[5]
766+ # The generate button is the first widget in the urwid.Columns.
767+ generate_button = generate.contents[0][0]
768+ # Click the generate button, and ensure the value has been generated.
769+ cli_helpers.emit(generate_button)
770+ self.assertEqual('auto-generated!', value_getter())
771+
772+ def test_choices(self):
773+ # Possible choices are properly displayed below the edit widget.
774+ field = fields.ChoiceField('captain', choices=('Kirk', 'Picard'))
775+ widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')
776+ choices = self.inspect_widget(widget, field)[6]
777+ self.assertEqual(
778+ 'possible values are Kirk, Picard, '
779+ 'but this field can also be left empty',
780+ choices.text)
781+
782+ def test_choices_required_field(self):
783+ # Possible choices are properly displayed below the edit widget for
784+ # required fields.
785+ field = fields.ChoiceField(
786+ 'captain', required=True, choices=('Janeway', 'Sisko'))
787+ widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')
788+ choices = self.inspect_widget(widget, field)[6]
789+ self.assertEqual('possible values are Janeway, Sisko', choices.text)
790+
791+
792+class TestCreateBoolWidget(unittest.TestCase):
793+
794+ def inspect_widget(self, widget, field):
795+ """Return a list of sub-widgets composing the given boolean widget.
796+
797+ The sub-widgets are:
798+ - the checkbox widget;
799+ - the help widget (or None if not present);
800+ - the error text widget (or None if not present);
801+ """
802+ help = error = None
803+ # Retrieve the Pile contents ignoring the last Divider widget.
804+ contents = list(widget.contents)[:-1]
805+ first_widget = contents.pop(0)[0]
806+ if isinstance(first_widget, urwid.Text):
807+ # This is the error message.
808+ error = first_widget
809+ checkbox = contents.pop(0)[0]
810+ else:
811+ # The widget has no errors.
812+ checkbox = first_widget
813+ if field.help:
814+ help = contents.pop(0)[0]
815+ return checkbox, help, error
816+
817+ def test_widget_structure(self):
818+ # The widget includes all the information about a field.
819+ field = fields.BoolField(
820+ 'enabled', label='is enabled', help='something is enabled')
821+ widget, _ = forms.create_bool_widget(field, False, 'bad wolf')
822+ checkbox, help, error = self.inspect_widget(widget, field)
823+ # Since the field is not read-only, the widget is properly enabled.
824+ self.assertNotIsInstance(checkbox, urwid.WidgetDisable)
825+ # The checkbox widget is set to the given value.
826+ self.assertFalse(checkbox.get_state())
827+ # The help message is properly displayed.
828+ self.assertEqual('something is enabled', help.text)
829+ # The error is displayed.
830+ self.assertEqual('bad wolf', error.text)
831+
832+ def test_value_getter(self):
833+ # The returned value getter function returns the current widget value.
834+ field = fields.BoolField('enabled')
835+ widget, value_getter = forms.create_bool_widget(field, True, None)
836+ checkbox = self.inspect_widget(widget, field)[0]
837+ self.assertTrue(value_getter())
838+ # The value getter is lazy and always returns the current value.
839+ checkbox.set_state(False)
840+ self.assertFalse(value_getter())
841+
842+ def test_allow_mixed(self):
843+ # The checkbox has three possible states if allow_mixed is True.
844+ field = fields.BoolField('enabled', allow_mixed=True)
845+ widget, value_getter = forms.create_bool_widget(field, None, None)
846+ checkbox = self.inspect_widget(widget, field)[0]
847+ self.assertTrue(checkbox.has_mixed)
848+ self.assertEqual(forms.MIXED, checkbox.get_state())
849+ # A mixed value is converted to None when the value is retrieved.
850+ self.assertIsNone(value_getter())
851+
852+ def test_mixed_not_allowed(self):
853+ # The checkbox can only be in a True/False state if the field's
854+ # allow_mixed is set to False.
855+ field = fields.BoolField('enabled', allow_mixed=False, default=True)
856+ widget, value_getter = forms.create_bool_widget(field, None, None)
857+ checkbox = self.inspect_widget(widget, field)[0]
858+ self.assertFalse(checkbox.has_mixed)
859+ # The default value is used if the input value is unset and mixed state
860+ # is not allowed.
861+ self.assertTrue(checkbox.get_state())
862+ # The retrieved value reflects the checkbox internal state.
863+ self.assertTrue(value_getter())
864+
865+ def test_readonly_field(self):
866+ # The widget is disabled if the field is read-only.
867+ field = fields.BoolField('enabled', readonly=True)
868+ widget, _ = forms.create_bool_widget(field, False, None)
869+ checkbox = self.inspect_widget(widget, field)[0]
870+ self.assertIsInstance(checkbox, urwid.WidgetDisable)
871+
872+
873+class TestCreateForm(unittest.TestCase):
874+
875+ field_value_pairs = (
876+ (fields.StringField('first-name'), 'Jean-Luc'),
877+ (fields.BoolField('enabled'), True),
878+ )
879+
880+ def test_form_creation(self):
881+ # The create_form factory correctly creates and returns the form
882+ # widgets for each field/value pair provided.
883+ widgets, _ = forms.create_form(self.field_value_pairs, {})
884+ self.assertEqual(2, len(widgets))
885+ string_widget, bool_widget = widgets
886+ caption = string_widget.contents[0][0].base_widget.contents[0][0].text
887+ self.assertEqual('first-name: ', caption)
888+ checkbox_label = bool_widget.contents[0][0].label
889+ self.assertEqual('enabled', checkbox_label)
890+
891+ def test_values_getter(self):
892+ # The returned values getter function returns the current form values.
893+ widgets, values_getter = forms.create_form(self.field_value_pairs, {})
894+ self.assertEqual(
895+ {'enabled': True, 'first-name': u'Jean-Luc'}, values_getter())
896+ # The values getter is lazy and always returns the current values.
897+ bool_widget = widgets[1]
898+ checkbox = bool_widget.contents[0][0]
899+ checkbox.set_state(False)
900+ self.assertEqual(
901+ {'enabled': False, 'first-name': u'Jean-Luc'}, values_getter())
902+
903+ def test_error_message(self):
904+ # The user is asked to correct the form errors.
905+ errors = {'first-name': 'invalid name'}
906+ widgets, _ = forms.create_form(self.field_value_pairs, errors)
907+ message = widgets[0]
908+ self.assertEqual('please correct the error below', message.text)
909+
910+ def test_multiple_error_messages(self):
911+ # The user is asked to correct multiple form errors.
912+ errors = {'first-name': 'invalid name', 'enabled': 'invalid value'}
913+ widgets, _ = forms.create_form(self.field_value_pairs, errors)
914+ message = widgets[0]
915+ self.assertEqual('please correct the 2 errors below', message.text)
916+
917+ def test_errros(self):
918+ # Widgets are created passing the corresponding errors.
919+ errors = {'first-name': 'invalid name'}
920+ widgets, _ = forms.create_form(self.field_value_pairs, errors)
921+ # The first widget is the error message, the second is a divider.
922+ string_widget = widgets[2]
923+ error = string_widget.contents[0][0]
924+ self.assertEqual('invalid name', error.text)
925+
926+
927+class TestCreateActions(unittest.TestCase):
928+
929+ def setUp(self):
930+ # Set up actions.
931+ self.clicked = []
932+ actions = [
933+ ('load', ui.thunk(self.clicked.append, 'load clicked')),
934+ ('save', ui.thunk(self.clicked.append, 'save clicked')),
935+ ]
936+ # Create the controls and retrieve the menu buttons.
937+ controls = forms.create_actions(actions)
938+ columns = controls.contents[1][0].base_widget
939+ self.buttons = [widget.base_widget for widget, _ in columns.contents]
940+
941+ def test_resulting_controls(self):
942+ # A menu button is created for each action.
943+ self.assertEqual(2, len(self.buttons))
944+ load_button, save_button = self.buttons
945+ self.assertEqual('load', cli_helpers.get_button_caption(load_button))
946+ self.assertEqual('save', cli_helpers.get_button_caption(save_button))
947+
948+ def test_callbacks(self):
949+ # The action callbacks are properly attached to each menu button.
950+ load_button, save_button = self.buttons
951+ cli_helpers.emit(load_button)
952+ self.assertEqual(['load clicked'], self.clicked)
953+ cli_helpers.emit(save_button)
954+ self.assertEqual(['load clicked', 'save clicked'], self.clicked)
955
956=== modified file 'quickstart/tests/cli/test_views.py'
957--- quickstart/tests/cli/test_views.py 2013-12-20 10:30:37 +0000
958+++ quickstart/tests/cli/test_views.py 2014-01-03 11:38:58 +0000
959@@ -26,6 +26,7 @@
960
961 from quickstart.cli import (
962 base,
963+ forms,
964 ui,
965 views,
966 )
967@@ -96,7 +97,7 @@
968 Use the filter_function argument to filter the returned list.
969 """
970 contents = self.app.get_contents()
971- return filter(filter_function, list(contents.body))
972+ return filter(filter_function, list(contents.base_widget.body))
973
974 def get_control_buttons(self):
975 """Return the list of buttons included in a control box.
976@@ -138,7 +139,7 @@
977 env_db = helpers.make_env_db()
978 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
979 self.assertEqual(
980- 'Select the Juju environment you want to use',
981+ 'Select an existing Juju environment or create a new one',
982 self.app.get_title())
983
984 def test_view_title_no_environments(self):
985@@ -149,17 +150,28 @@
986 'No Juju environments already set up: please create one',
987 self.app.get_title())
988
989+ def test_view_contents(self):
990+ # The view displays a list of the environments in env_db, and buttons
991+ # to create new environments.
992+ env_db = helpers.make_env_db()
993+ views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
994+ buttons = self.get_widgets_in_contents(
995+ filter_function=self.is_a(ui.MenuButton))
996+ # A button is created for each existing environment (see details) and
997+ # for each environment type supported by quickstart (create).
998+ env_types = envs.get_supported_env_types(self.env_type_db)
999+ expected_buttons_number = len(env_db['environments']) + len(env_types)
1000+ self.assertEqual(expected_buttons_number, len(buttons))
1001+
1002 @mock.patch('quickstart.cli.views.env_detail')
1003- def test_view_contents(self, mock_env_detail):
1004- # The view displays a list of the environments in env_db.
1005+ def test_environment_clicked(self, mock_env_detail):
1006+ # The environment detail view is called when clicking an environment.
1007 env_db = helpers.make_env_db()
1008 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
1009 buttons = self.get_widgets_in_contents(
1010 filter_function=self.is_a(ui.MenuButton))
1011 # The environments are listed in alphabetical order.
1012 environments = sorted(env_db['environments'])
1013- # A button is created for each environment.
1014- self.assertEqual(len(environments), len(buttons))
1015 for env_name, button in zip(environments, buttons):
1016 env_data = envs.get_env_data(env_db, env_name)
1017 # The caption includes the environment description.
1018@@ -176,6 +188,30 @@
1019 # loop cycle.
1020 mock_env_detail.reset_mock()
1021
1022+ @mock.patch('quickstart.cli.views.env_edit')
1023+ def test_create_new_environment_clicked(self, mock_env_edit):
1024+ # The environment edit view is called when clicking to create a new
1025+ # environment.
1026+ env_db = helpers.make_env_db()
1027+ views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
1028+ buttons = self.get_widgets_in_contents(
1029+ filter_function=self.is_a(ui.MenuButton))
1030+ env_types = envs.get_supported_env_types(self.env_type_db)
1031+ for env_type, button in zip(env_types, buttons[-len(env_types):]):
1032+ # The caption includes the environment type.
1033+ expected_caption = 'new {} environment'.format(env_type)
1034+ caption = cli_helpers.get_button_caption(button)
1035+ self.assertIn(expected_caption, caption)
1036+ # When the button is clicked, the edit view is called passing the
1037+ # corresponding environment data.
1038+ cli_helpers.emit(button)
1039+ mock_env_edit.assert_called_once_with(
1040+ self.app, self.env_type_db, env_db, self.save_callable,
1041+ {'type': env_type})
1042+ # Reset the mock so that it does not include any calls on the next
1043+ # loop cycle.
1044+ mock_env_edit.reset_mock()
1045+
1046 def test_selected_environment(self):
1047 # The default environment is already selected in the list.
1048 env_db = helpers.make_env_db(default='lxc')
1049@@ -261,28 +297,30 @@
1050 self.assertEqual(expected_text, widget.text)
1051
1052 def test_view_buttons(self):
1053- # The following buttons are displayed: "back", "use", "set default" and
1054- # "remove".
1055+ # The following buttons are displayed: "back", "use", "set default",
1056+ # "edit" and "remove".
1057 self.call_view(env_name='ec2-west')
1058 buttons = self.get_control_buttons()
1059 captions = map(cli_helpers.get_button_caption, buttons)
1060- self.assertEqual(['back', 'use', 'set default', 'remove'], captions)
1061+ self.assertEqual(
1062+ ['back', 'use', 'set default', 'edit', 'remove'], captions)
1063
1064 def test_view_buttons_default(self):
1065 # If the environment is the default one, the "set default" button is
1066- # not displayed. The buttons we expect are "back", "use" and "remove".
1067+ # not displayed. The buttons we expect are "back", "use", "edit" and
1068+ # "remove".
1069 self.call_view(env_name='lxc')
1070 buttons = self.get_control_buttons()
1071 captions = map(cli_helpers.get_button_caption, buttons)
1072- self.assertEqual(['back', 'use', 'remove'], captions)
1073+ self.assertEqual(['back', 'use', 'edit', 'remove'], captions)
1074
1075 def test_view_buttons_error(self):
1076 # If the environment is not valid, the "use" button is not displayed.
1077- # The buttons we expect are "back", "set default" and "remove".
1078+ # The buttons we expect are "back", "set default", "edit" and "remove".
1079 self.call_view(env_name='local-with-errors')
1080 buttons = self.get_control_buttons()
1081 captions = map(cli_helpers.get_button_caption, buttons)
1082- self.assertEqual(['back', 'set default', 'remove'], captions)
1083+ self.assertEqual(['back', 'set default', 'edit', 'remove'], captions)
1084
1085 @mock.patch('quickstart.cli.views.env_index')
1086 def test_back_button(self, mock_env_index):
1087@@ -323,6 +361,17 @@
1088 # The new env_db has been saved.
1089 self.save_callable.assert_called_once_with(new_env_db)
1090
1091+ @mock.patch('quickstart.cli.views.env_edit')
1092+ def test_edit_button(self, mock_env_edit):
1093+ # The edit view is called if the "edit" button is clicked.
1094+ self.call_view(env_name='ec2-west')
1095+ # The "edit" button is the fourth one.
1096+ edit_button = self.get_control_buttons()[3]
1097+ cli_helpers.emit(edit_button)
1098+ mock_env_edit.assert_called_once_with(
1099+ self.app, self.env_type_db, self.env_db, self.save_callable,
1100+ self.env_data)
1101+
1102 def test_remove_button(self):
1103 # A confirmation dialog is displayed if the "remove" button is clicked.
1104 self.call_view(env_name='ec2-west')
1105@@ -392,3 +441,213 @@
1106 self.call_view(env_name='lxc')
1107 status = self.app.get_status()
1108 self.assertEqual('', status)
1109+
1110+
1111+class TestEnvEdit(EnvViewTestsMixin, unittest.TestCase):
1112+
1113+ env_db = helpers.make_env_db(default='lxc')
1114+
1115+ def call_view(self, env_name='lxc', env_type=None):
1116+ """Call the view passing the env_data corresponding to env_name.
1117+
1118+ If env_type provider, the view is a creation form, env_name is ignored
1119+ and a new env_data is passed to the view.
1120+ """
1121+ if env_type is None:
1122+ self.env_data = envs.get_env_data(self.env_db, env_name)
1123+ else:
1124+ self.env_data = {'type': env_type}
1125+ return views.env_edit(
1126+ self.app, self.env_type_db, self.env_db, self.save_callable,
1127+ self.env_data)
1128+
1129+ def get_form_contents(self):
1130+ """Return the form contents included in the app page.
1131+
1132+ The contents are returned as a sequence of (caption, value) tuples.
1133+ """
1134+ pile_widgets = self.get_widgets_in_contents(
1135+ filter_function=self.is_a(urwid.Pile))
1136+ form_contents = []
1137+ for pile_widget in pile_widgets:
1138+ base_widget = pile_widget.contents[0][0].base_widget
1139+ if isinstance(base_widget, urwid.CheckBox):
1140+ # This is a boolean widget.
1141+ form_contents.append((
1142+ base_widget.label, base_widget.get_state()))
1143+ elif hasattr(base_widget, 'contents'):
1144+ # This is a string widget.
1145+ caption = base_widget.contents[0][0].text
1146+ value = base_widget.contents[1][0].get_edit_text()
1147+ form_contents.append((caption, value))
1148+ return form_contents
1149+
1150+ def patch_create_form(self, changes=None):
1151+ """Patch the forms.create_form function.
1152+
1153+ The create_form function returns the form widgets and a callable
1154+ returning the new env data. Make the latter return the current
1155+ self.env_data instead, optionally updated using the given changes.
1156+ """
1157+ original_create_form = forms.create_form
1158+ testcase = self
1159+
1160+ class MockCreateForm(object):
1161+
1162+ call_count = 0
1163+ errors = None
1164+ new_env_data = None
1165+
1166+ def __call__(self, field_value_pairs, errors):
1167+ self.call_count += 1
1168+ self.errors = errors
1169+ self.new_env_data = testcase.env_data.copy()
1170+ if changes is not None:
1171+ self.new_env_data.update(changes)
1172+ form_widgets, _ = original_create_form(
1173+ field_value_pairs, errors)
1174+ return form_widgets, lambda: self.new_env_data
1175+
1176+ @property
1177+ def called(self):
1178+ return bool(self.call_count)
1179+
1180+ return mock.patch('quickstart.cli.forms.create_form', MockCreateForm())
1181+
1182+ def test_view_default_return_value_on_exit(self):
1183+ # The view configures the app so that the return value on user exit is
1184+ # a tuple including a copy of the given env_db and None, the latter
1185+ # meaning no environment has been selected (for now).
1186+ self.call_view()
1187+ new_env_db, env_data = self.get_on_exit_return_value(self.loop)
1188+ self.assertEqual(self.env_db, new_env_db)
1189+ self.assertIsNot(self.env_db, new_env_db)
1190+ self.assertIsNone(env_data)
1191+
1192+ def test_creation_view_title(self):
1193+ # The application title is correctly set up when the view is used to
1194+ # create a new environment.
1195+ self.call_view(env_type='ec2')
1196+ self.assertEqual('Create a new ec2 environment', self.app.get_title())
1197+
1198+ def test_modification_view_title(self):
1199+ # The application title is correctly set up when the view is used to
1200+ # change an existing environment.
1201+ self.call_view(env_name='lxc')
1202+ self.assertEqual('Edit the local environment', self.app.get_title())
1203+
1204+ def test_view_contents_description(self):
1205+ # The view displays the provider description.
1206+ self.call_view()
1207+ text_widgets = self.get_widgets_in_contents(
1208+ filter_function=self.is_a(urwid.Text))
1209+ description = text_widgets[0]
1210+ expected_description = self.env_type_db['local']['description']
1211+ self.assertEqual(expected_description, description.text)
1212+
1213+ def test_view_contents_form(self):
1214+ # The view displays a form containing all the environment fields.
1215+ self.call_view()
1216+ expected_form_contents = [
1217+ ('provider type: ', 'local'),
1218+ ('environment name: ', 'lxc'),
1219+ ('admin secret: ', 'bones'),
1220+ ('default series: ', 'raring'),
1221+ ('root dir: ', ''),
1222+ ('storage port: ', '8888'),
1223+ ('shared storage port: ', ''),
1224+ ('network bridge: ', ''),
1225+ ('default', True),
1226+ ]
1227+ self.assertEqual(expected_form_contents, self.get_form_contents())
1228+
1229+ def test_view_buttons(self):
1230+ # The following buttons are displayed: "save", "cancel" and "restore".
1231+ self.call_view(env_name='ec2-west')
1232+ buttons = self.get_control_buttons()
1233+ captions = map(cli_helpers.get_button_caption, buttons)
1234+ self.assertEqual(['save', 'cancel', 'restore'], captions)
1235+
1236+ @mock.patch('quickstart.cli.views.env_detail')
1237+ def test_save_button(self, mock_env_detail):
1238+ # The new data is saved if the user clicks the save button.
1239+ # Subsequently the user is redirected to the env detail view.
1240+ changes = {'admin-secret': 'Secret!'}
1241+ with self.patch_create_form(changes=changes) as mock_create_form:
1242+ self.call_view(env_name='ec2-west')
1243+ self.assertTrue(mock_create_form.called)
1244+ # The "save" button is the first one.
1245+ save_button = self.get_control_buttons()[0]
1246+ cli_helpers.emit(save_button)
1247+ # At this point the new env data should be normalized and saved into
1248+ # the environments database.
1249+ env_metadata = envs.get_env_metadata(self.env_type_db, self.env_data)
1250+ new_env_data = envs.normalize(
1251+ env_metadata, mock_create_form.new_env_data)
1252+ envs.set_env_data(self.env_db, 'ec2-west', new_env_data)
1253+ # The new data has been correctly saved.
1254+ self.save_callable.assert_called_once_with(self.env_db)
1255+ # A message notifies the environment has been saved.
1256+ self.assertEqual(
1257+ 'ec2-west successfully modified', self.app.get_message())
1258+ # The application displays the environment detail view.
1259+ mock_env_detail.assert_called_once_with(
1260+ self.app, self.env_type_db, self.env_db, self.save_callable,
1261+ new_env_data)
1262+
1263+ def test_save_invalid_form_data(self):
1264+ # Errors are displayed if the user tries to save invalid data.
1265+ changes = {'admin-secret': ''}
1266+ with self.patch_create_form(changes=changes) as mock_create_form:
1267+ self.call_view(env_name='ec2-west')
1268+ self.assertTrue(mock_create_form.called)
1269+ # The "save" button is the first one.
1270+ save_button = self.get_control_buttons()[0]
1271+ cli_helpers.emit(save_button)
1272+ # In case of errors, the save callable is not called.
1273+ self.assertFalse(self.save_callable.called)
1274+ # The form has been re-rendered passing the errors.
1275+ self.assertEqual(2, mock_create_form.call_count)
1276+ self.assertEqual(
1277+ {'admin-secret': 'a value is required for the admin secret field'},
1278+ mock_create_form.errors)
1279+
1280+ def test_save_invalid_new_name(self):
1281+ # It is not allowed to save an environment with an already used name.
1282+ changes = {'name': 'lxc'}
1283+ with self.patch_create_form(changes=changes) as mock_create_form:
1284+ self.call_view(env_name='ec2-west')
1285+ self.assertTrue(mock_create_form.called)
1286+ # The "save" button is the first one.
1287+ save_button = self.get_control_buttons()[0]
1288+ cli_helpers.emit(save_button)
1289+ # In case of errors, the save callable is not called.
1290+ self.assertFalse(self.save_callable.called)
1291+ # The form has been re-rendered passing the errors.
1292+ self.assertEqual(2, mock_create_form.call_count)
1293+ self.assertEqual(
1294+ {'name': 'an environment with this name already exists'},
1295+ mock_create_form.errors)
1296+
1297+ @mock.patch('quickstart.cli.views.env_index')
1298+ def test_creation_view_cancel_button(self, mock_env_index):
1299+ # The index view is called if the "cancel" button is clicked while
1300+ # creating a new environment.
1301+ self.call_view(env_type='ec2')
1302+ # The "cancel" button is the second one.
1303+ cancel_button = self.get_control_buttons()[1]
1304+ cli_helpers.emit(cancel_button)
1305+ mock_env_index.assert_called_once_with(
1306+ self.app, self.env_type_db, self.env_db, self.save_callable)
1307+
1308+ @mock.patch('quickstart.cli.views.env_detail')
1309+ def test_modification_view_cancel_button(self, mock_env_detail):
1310+ # The index view is called if the "cancel" button is clicked while
1311+ # creating a new environment.
1312+ self.call_view(env_name='ec2-west')
1313+ # The "cancel" button is the second one.
1314+ cancel_button = self.get_control_buttons()[1]
1315+ cli_helpers.emit(cancel_button)
1316+ mock_env_detail.assert_called_once_with(
1317+ self.app, self.env_type_db, self.env_db, self.save_callable,
1318+ self.env_data)
1319
1320=== modified file 'quickstart/tests/models/test_envs.py'
1321--- quickstart/tests/models/test_envs.py 2013-12-19 17:00:36 +0000
1322+++ quickstart/tests/models/test_envs.py 2014-01-03 11:38:58 +0000
1323@@ -386,6 +386,20 @@
1324 self.env_db['environments']['new-one'])
1325 self.assertEqual('new-one', self.env_db['default'])
1326
1327+ def test_new_environment_with_no_default(self):
1328+ # A new environment is properly added in an env_db with no default.
1329+ env_data = {
1330+ 'default-series': 'edgy',
1331+ 'is-default': False,
1332+ 'name': 'new-one',
1333+ }
1334+ del self.env_db['default']
1335+ envs.set_env_data(self.env_db, None, env_data)
1336+ self.assertEqual(
1337+ {'default-series': 'edgy'},
1338+ self.env_db['environments']['new-one'])
1339+ self.assertNotIn('default', self.env_db)
1340+
1341 def test_existing_environment_updated(self):
1342 # An existing environment is properly updated.
1343 env_data = {

Subscribers

People subscribed via source and target branches