Merge lp:~frankban/juju-quickstart/env-manage-edit into lp:juju-quickstart
- env-manage-edit
- Merge into trunk
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 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email: mp+200305@code.launchpad.net |
Commit message
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-
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.
Francesco Banconi (frankban) wrote : | # |
Francesco Banconi (frankban) wrote : | # |
Sorry for the long diff, tests for views are a little verbose.
Gary Poster (gary) 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.
- 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:/
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://
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...
Benji York (benji) wrote : | # |
Looks great.
LGTM-ly, yrs
Benji
https:/
File quickstart/
https:/
quickstart/
This is so small it is almost not worth mentioning, but I thought you
might like it:
return ui.create_controls(
*(
actions))
Or if we want to go full-on functional:
return ui.create_
https:/
File quickstart/
https:/
quickstart/
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:/
File quickstart/
https:/
quickstart/
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:/
File quickstart/
https:/
quickstart/
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.
Gary Poster (gary) wrote : | # |
LGTM! Thank you!
https:/
File quickstart/
https:/
quickstart/
error):
Nice behavior/approach for the widget generation. I like it.
https:/
quickstart/
For readability, I wonder if we ought to stick most of this block in a
separate function.
if generate_method is not None:
widgets.
...or similar?
https:/
quickstart/
Hah, ok, cool, that's what I mentioned in my qa. :-)
https:/
quickstart/
errors below')),
Nice to have: a count. ("Please correct the 2 errors below")
https:/
quickstart/
checkboxes.
s/as/are/
Boolean values are represented a checkboxes
https:/
quickstart/
value_getter in form.items())
Nice :-)
https:/
File quickstart/
https:/
quickstart/
enumerate(
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:/
quickstart/
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:/
File quickstart/
https:/
- 55. By Francesco Banconi
-
Form changes as per review.
Francesco Banconi (frankban) wrote : | # |
Please take a look.
https:/
File quickstart/
https:/
quickstart/
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_
> ...or similar?
Done.
https:/
quickstart/
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:/
quickstart/
checkboxes.
On 2014/01/02 15:19:28, gary.poster wrote:
> s/as/are/
> Boolean values are represented a checkboxes
Done.
https:/
quickstart/
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
actions))
> Or if we want to go full-on functional:
> return ui.create_
Done.
https:/
File quickstart/
https:/
quickstart/
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://
But I can be wrong, and maybe it is worth testing it.
https:/
File quickstart/
https:/
quickstart/
enumerate(
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...
Francesco Banconi (frankban) wrote : | # |
Now working on the QA comments.
In the meanwhile, thank you for the great reviews!
Gary Poster (gary) wrote : | # |
https:/
File quickstart/
https:/
quickstart/
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.
- 56. By Francesco Banconi
-
Footer fixes.
- 57. By Francesco Banconi
-
Footer fixes.
- 58. By Francesco Banconi
-
Changes as per QA.
Francesco Banconi (frankban) wrote : | # |
Please take a look.
Francesco Banconi (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.
> - 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?
> ...
Gary Poster (gary) wrote : | # |
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...
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-
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:/
Preview Diff
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 = { |
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 app-demo. py).
(`make` and `./cli-
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): cli/forms. py cli/ui. py cli/views. py models/ envs.py tests/cli/ test_forms. py tests/cli/ test_views. py tests/models/ test_envs. py
A [revision details]
A quickstart/
M quickstart/
M quickstart/
M quickstart/
A quickstart/
M quickstart/
M quickstart/