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

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 40
Proposed branch: lp:~frankban/juju-quickstart/env-manage-remove-environment
Merge into: lp:juju-quickstart
Diff against target: 562 lines (+322/-39)
7 files modified
quickstart/cli/ui.py (+67/-0)
quickstart/cli/views.py (+22/-2)
quickstart/models/envs.py (+15/-0)
quickstart/tests/cli/helpers.py (+35/-0)
quickstart/tests/cli/test_ui.py (+70/-0)
quickstart/tests/cli/test_views.py (+79/-31)
quickstart/tests/models/test_envs.py (+34/-6)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/env-manage-remove-environment
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+199698@code.launchpad.net

Description of the change

Implement the remove env functionality.

It is now possible to remove environments
using the interactive session.

A confirmation dialog prevents the user
to accidentally remove an environment.

Tests: `make check`.
QA: start the demo app
(`make` and `./cli-app-demo.py).
Remove environments at will: exiting
from the app you should see the
new environments YAML without the
environments you deleted.
Any UX suggestion welcome.
Thank you!

https://codereview.appspot.com/44310043/

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

Reviewers: mp+199698_code.launchpad.net,

Message:
Please take a look.

Description:
Implement the remove env functionality.

It is now possible to remove environments
using the interactive session.

A confirmation dialog prevents the user
to accidentally remove an environment.

Tests: `make check`.
QA: start the demo app
(`make` and `./cli-app-demo.py).
Remove environments at will: exiting
from the app you should see the
new environments YAML without the
environments you deleted.
Any UX suggestion welcome.
Thank you!

https://code.launchpad.net/~frankban/juju-quickstart/env-manage-remove-environment/+merge/199698

(do not edit description out of merge proposal)

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

Affected files (+306, -30 lines):
   A [revision details]
   M quickstart/cli/ui.py
   M quickstart/cli/views.py
   M quickstart/models/envs.py
   M quickstart/tests/cli/helpers.py
   M quickstart/tests/cli/test_ui.py
   M quickstart/tests/cli/test_views.py
   M quickstart/tests/models/test_envs.py

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

LGTM.

The warning says "This action cannot be undone" but really it won't take
effect if the person exits early (^X), right? Because we only will save
at the end? If I'm right, maybe the following would work?

"""
Remove the private-cloud-errors-environment

This change will be saved once you choose an environment to deploy. The
action cannot be undone.
"""

I deleted my default. What is supposed to happen then? You don't have
a default anymore? (That's what happens now.)

QA good with those notes.

Thanks!

Gary

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

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py#newcode129
quickstart/cli/ui.py:129: original_contents = app.get_contents()
The way you can use this with the dismiss thunk is cool. Feels nice.

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

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/views.py#newcode277
quickstart/cli/views.py:277: ('control alert', 'remove'),
ui.thunk(confirm_removal, env_data)),
cool. I really like how this and the show_dialog actions work.

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

https://codereview.appspot.com/44310043/diff/1/quickstart/tests/cli/helpers.py#newcode39
quickstart/tests/cli/helpers.py:39: def emit(self, widget):
Don't you have this in another base class? If so, maybe this ought to
be a standalone function? it does not require "self."

https://codereview.appspot.com/44310043/

Revision history for this message
Brad Crittenden (bac) wrote :

LGTM

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

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py#newcode125
quickstart/cli/ui.py:125: the function tries to generate suitable
defaults.
If the units are pixels please say so.

https://codereview.appspot.com/44310043/

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

On 2013/12/19 19:11:13, gary.poster wrote:
> LGTM.

> The warning says "This action cannot be undone" but really it won't
take effect
> if the person exits early (^X), right? Because we only will save at
the end?
> If I'm right, maybe the following would work?

In the current implementation, you pass a save_callable to the views,
which is
called when required, e.g. when the user removes an environment.
The save_callable accepts an env_db and it's not yet implemented:
it will likely be something like functools.partial(envs.save, env_file).
We can decide to change this, but I think this strategy gives us two
things:
1) the UX is less surprising for the user: when he performs an action,
that
action is done, and not just scheduled;
2) it is possible to use the quickstart interactive session to manage
environments without starting Juju: the user starts quickstart,
creates/edits/removes environments and then exits (^X).

> """
> Remove the private-cloud-errors-environment

> This change will be saved once you choose an environment to deploy.
The action
> cannot be undone.
> """

> I deleted my default. What is supposed to happen then? You don't
have a
> default anymore? (That's what happens now.)

Yes, in that case there is no longer a default in the environments.yaml
file, and
I have to check if juju-core is ok with this. If not, we need to change
this behavior. It's also important to define the concept of "default"
in the quickstart context: for now in Urwid it is just the YAML default,
but
in the real world it can be overwritten by juju switch (which, in turn,
looks for that value in the JUJU_ENV env var and in the current_env
file,
the latter being an implementation detail). When you run quickstart
without specifying -e, the default environment from juju switch is
started.
In the interactive session, the default environment is instead the one
in the
YAML file, and that's because the interactive session is intended to be
a CRUD for envs.yaml. We might want to either unify those two concepts
or
keep them separate and show both in the UX.

> QA good with those notes.

> Thanks!

Thank you!

https://codereview.appspot.com/44310043/diff/1/quickstart/tests/cli/helpers.py#newcode39
> quickstart/tests/cli/helpers.py:39: def emit(self, widget):
> Don't you have this in another base class? If so, maybe this ought to
be a
> standalone function? it does not require "self."

We had that, but now it is abstracted in this Mixin, used by
both test cases. That said, you are right, this does not
require self, so it can be just a function. The same for
get_button_caption and inspect_dialog. Fixed.

https://codereview.appspot.com/44310043/

52. By Francesco Banconi

Changes as per review.

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

*** Submitted:

Implement the remove env functionality.

It is now possible to remove environments
using the interactive session.

A confirmation dialog prevents the user
to accidentally remove an environment.

Tests: `make check`.
QA: start the demo app
(`make` and `./cli-app-demo.py).
Remove environments at will: exiting
from the app you should see the
new environments YAML without the
environments you deleted.
Any UX suggestion welcome.
Thank you!

R=gary.poster, bac
CC=
https://codereview.appspot.com/44310043

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

https://codereview.appspot.com/44310043/diff/1/quickstart/cli/ui.py#newcode125
quickstart/cli/ui.py:125: the function tries to generate suitable
defaults.
On 2013/12/19 20:29:17, bac wrote:
> If the units are pixels please say so.

Those are Urwid specific values: if you pass
a number, the units are rows/columns, but you
can provide also other values: see
http://excess.org/urwid/docs/reference/widget.html#overlay
Updated the docstring.

https://codereview.appspot.com/44310043/

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

Thank you both for the reviews!

https://codereview.appspot.com/44310043/

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

On 12/20/2013 05:24 AM, Francesco Banconi wrote:
> On 2013/12/19 19:11:13, gary.poster wrote:
>> LGTM.
>
>> The warning says "This action cannot be undone" but really it won't
> take effect
>> if the person exits early (^X), right? Because we only will save at
> the end?
>> If I'm right, maybe the following would work?
>
> In the current implementation, you pass a save_callable to the views,
> which is
> called when required, e.g. when the user removes an environment.
> The save_callable accepts an env_db and it's not yet implemented:
> it will likely be something like functools.partial(envs.save, env_file).
> We can decide to change this, but I think this strategy gives us two
> things:
> 1) the UX is less surprising for the user: when he performs an action,
> that
> action is done, and not just scheduled;

+1. I misunderstood the plan.

> 2) it is possible to use the quickstart interactive session to manage
> environments without starting Juju: the user starts quickstart,
> creates/edits/removes environments and then exits (^X).
>
>
>> """
>> Remove the private-cloud-errors-environment
>
>> This change will be saved once you choose an environment to deploy.
> The action
>> cannot be undone.
>> """
>
>> I deleted my default. What is supposed to happen then? You don't
> have a
>> default anymore? (That's what happens now.)
>
> Yes, in that case there is no longer a default in the environments.yaml
> file, and
> I have to check if juju-core is ok with this. If not, we need to change
> this behavior. It's also important to define the concept of "default"
> in the quickstart context: for now in Urwid it is just the YAML default,
> but
> in the real world it can be overwritten by juju switch (which, in turn,
> looks for that value in the JUJU_ENV env var and in the current_env
> file,
> the latter being an implementation detail). When you run quickstart
> without specifying -e, the default environment from juju switch is
> started.
> In the interactive session, the default environment is instead the one
> in the
> YAML file, and that's because the interactive session is intended to be
> a CRUD for envs.yaml. We might want to either unify those two concepts
> or
> keep them separate and show both in the UX.

Ah, yeah, that's a bit confusing (in Juju itself).

>
>
>> QA good with those notes.
>
>> Thanks!
>
> Thank you!
>
>
>
> https://codereview.appspot.com/44310043/diff/1/quickstart/tests/cli/helpers.py#newcode39
>> quickstart/tests/cli/helpers.py:39: def emit(self, widget):
>> Don't you have this in another base class? If so, maybe this ought to
> be a
>> standalone function? it does not require "self."
>
> We had that, but now it is abstracted in this Mixin, used by
> both test cases. That said, you are right, this does not
> require self, so it can be just a function. The same for
> get_button_caption and inspect_dialog. Fixed.
>
>
>
> https://codereview.appspot.com/44310043/
>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'quickstart/cli/ui.py'
2--- quickstart/cli/ui.py 2013-12-18 11:34:12 +0000
3+++ quickstart/cli/ui.py 2013-12-20 10:30:54 +0000
4@@ -31,6 +31,9 @@
5 # See <http://excess.org/urwid/docs/reference/constants.html
6 # foreground-and-background-colors>.
7 (None, 'light gray', 'black'),
8+ ('dialog', 'dark gray', 'light gray'),
9+ ('dialog header', 'light gray,bold', 'dark blue'),
10+ ('control alert', 'light red', 'light gray'),
11 ('controls', 'dark gray', 'light gray'),
12 ('edit', 'white,underline', 'black'),
13 ('error', 'light red', 'black'),
14@@ -47,6 +50,7 @@
15 # Map attributes to new attributes to apply when the widget is selected.
16 FOCUS_MAP = {
17 None: 'selected',
18+ 'control alert': 'error selected',
19 'error': 'error selected',
20 }
21 # Define a default padding for the Urwid application.
22@@ -100,6 +104,69 @@
23 self._w = urwid.AttrMap(icon, None, FOCUS_MAP)
24
25
26+def show_dialog(
27+ app, title, message, actions=None, dismissable=True,
28+ width=None, height=None):
29+ """Display an interactive modal dialog.
30+
31+ This function receives the following arguments:
32+ - app: the App named tuple used to configure the current interactive
33+ session (see the quickstart.cli.base module);
34+ - title: the title of the message dialog;
35+ - message: the help message displayed in the dialog;
36+ - actions: the actions which can be executed from the dialog, as a
37+ sequence of (caption, callback) tuples. Those pairs are used to
38+ generate the clickable controls (MenuButton instances) displayed
39+ in the dialog;
40+ - dismissable: if set to True, a "cancel" button is prepended to the
41+ list of controls. Clicking the "cancel" button, the dialog just
42+ disappears without further changes to the state of the application;
43+ - width and height: optional dialog width and height as defined in
44+ Urwid, e.g. 'pack', 'relative' or the number of rows/columns.
45+ If not provided, the function tries to generate suitable defaults.
46+
47+ Return a function that can be called to dismiss the dialog.
48+ """
49+ original_contents = app.get_contents()
50+ # Set up the dialog's header.
51+ header = urwid.Pile([
52+ urwid.Divider(),
53+ urwid.Text(title, align='center'),
54+ urwid.Divider(),
55+ ])
56+ # Set up the controls displayed in the dialog.
57+ if actions is None:
58+ controls = []
59+ else:
60+ controls = [
61+ MenuButton(caption, callback) for caption, callback in actions]
62+ # The dialog is removed by restoring the view original contents.
63+ dismiss = thunk(app.set_contents, original_contents)
64+ if dismissable:
65+ controls.insert(0, MenuButton('cancel', dismiss))
66+ # Create the listbox that replaces the original view contents.
67+ widgets = [
68+ urwid.AttrMap(header, 'dialog header'),
69+ urwid.Divider(),
70+ urwid.Text(message, align='center'),
71+ create_controls(*controls),
72+ ]
73+ listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
74+ if width is None:
75+ # Calculate the dialog width: the max from the title/message length +
76+ # two padding spaces for each side.
77+ width = max(len(title), len(message)) + 4
78+ if height is None:
79+ # A height of eleven is usually enough for the dialog.
80+ height = 11
81+ contents = urwid.Overlay(
82+ urwid.AttrMap(listbox, 'dialog'), original_contents,
83+ align='center', width=width,
84+ valign='middle', height=height)
85+ app.set_contents(contents)
86+ return dismiss
87+
88+
89 def thunk(function, *args, **kwargs):
90 """Create and return a callable binding the given method and args/kwargs.
91
92
93=== modified file 'quickstart/cli/views.py'
94--- quickstart/cli/views.py 2013-12-19 11:10:46 +0000
95+++ quickstart/cli/views.py 2013-12-20 10:30:54 +0000
96@@ -228,6 +228,23 @@
97 app.set_message('{} successfully set as default'.format(env_name))
98 index_view()
99
100+ def remove(env_data):
101+ # The environment deletion is confirmed: remove the environment from
102+ # the database, save the new env_db and return to the index view.
103+ envs.remove_env(env_db, env_data['name'])
104+ save_callable(env_db)
105+ index_view()
106+
107+ def confirm_removal(env_data):
108+ # Ask confirmation before removing an environment.
109+ ui.show_dialog(
110+ app, 'Remove the {} environment'.format(env_data['name']),
111+ 'This action cannot be undone!',
112+ actions=[
113+ (('control alert', 'confirm'), ui.thunk(remove, env_data)),
114+ ],
115+ )
116+
117 env_metadata = envs.get_env_metadata(env_type_db, env_data)
118 app.set_title(envs.get_env_short_description(env_data))
119 app.set_status('')
120@@ -254,8 +271,11 @@
121 if not env_data['is-default']:
122 controls.append(
123 ui.MenuButton('set default', ui.thunk(set_default, env_data)))
124- # XXX frankban 2013-12-18: implement the "remove env" functionality.
125- # XXX frankban 2013-12-18: implement the env modification view.
126+ controls.extend([
127+ # XXX frankban 2013-12-18: implement the env modification view.
128+ ui.MenuButton(
129+ ('control alert', 'remove'), ui.thunk(confirm_removal, env_data)),
130+ ])
131 widgets.append(ui.create_controls(*controls))
132 listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
133 app.set_contents(listbox)
134
135=== modified file 'quickstart/models/envs.py'
136--- quickstart/models/envs.py 2013-12-17 15:30:48 +0000
137+++ quickstart/models/envs.py 2013-12-20 10:30:54 +0000
138@@ -296,6 +296,21 @@
139 del env_db['default']
140
141
142+def remove_env(env_db, env_name):
143+ """Remove the environment named env_name from the environments database.
144+
145+ Raise a ValueError if the environment is not present in env_db.
146+ Without errors, the env_db is modified in place and None is returned.
147+ """
148+ try:
149+ del env_db['environments'][env_name]
150+ except KeyError:
151+ raise ValueError(
152+ b'the environment named {!r} does not exist'.format(env_name))
153+ if env_db.get('default') == env_name:
154+ del env_db['default']
155+
156+
157 def get_env_type_db():
158 """Return the environments meta information based on provider types.
159
160
161=== modified file 'quickstart/tests/cli/helpers.py'
162--- quickstart/tests/cli/helpers.py 2013-12-19 17:41:40 +0000
163+++ quickstart/tests/cli/helpers.py 2013-12-20 10:30:54 +0000
164@@ -18,6 +18,8 @@
165
166 from __future__ import unicode_literals
167
168+import urwid
169+
170 from quickstart.cli import ui
171
172
173@@ -29,3 +31,36 @@
174 with self.assertRaises(ui.AppExit) as context_manager:
175 loop.unhandled_input(ui.EXIT_KEY)
176 return context_manager.exception.return_value
177+
178+
179+def get_button_caption(button):
180+ """Return the button caption as a string."""
181+ return button._w.original_widget.text
182+
183+
184+def emit(widget):
185+ """Emit the first signal associated withe the given widget.
186+
187+ This is usually invoked to click buttons.
188+ """
189+ # Retrieve the first signal name (usually is 'click').
190+ signal_name = widget.signals[0]
191+ urwid.emit_signal(widget, signal_name, widget)
192+
193+
194+def inspect_dialog(contents):
195+ """Inspect the widgets composing the dialog.
196+
197+ Return a tuple (title_widget, messaqe_widget, buttons) where:
198+ - title_widget is the Text widget displaying the dialog title;
199+ - message_widget is the Text widget displaying the dialog message;
200+ - buttons is a list of MenuButton widgets included in the dialog.
201+ """
202+ listbox = contents.top_w.base_widget
203+ header, _, message_widget, controls = listbox.body
204+ # The title widget is the second one in the header Pile.
205+ title_widget = header.base_widget.contents[1][0]
206+ # The button columns is the second widget in the Pile.
207+ columns = controls.contents[1][0].base_widget
208+ buttons = [content[0].base_widget for content in columns.contents]
209+ return title_widget, message_widget, buttons
210
211=== modified file 'quickstart/tests/cli/test_ui.py'
212--- quickstart/tests/cli/test_ui.py 2013-12-18 14:04:14 +0000
213+++ quickstart/tests/cli/test_ui.py 2013-12-20 10:30:54 +0000
214@@ -27,6 +27,7 @@
215 base,
216 ui,
217 )
218+from quickstart.tests.cli import helpers as cli_helpers
219
220
221 class TestAppExit(unittest.TestCase):
222@@ -95,6 +96,75 @@
223 callback.assert_called_once_with(button)
224
225
226+class TestShowDialog(cli_helpers.CliAppTestsMixin, unittest.TestCase):
227+
228+ def setUp(self):
229+ # Set up the base Urwid application.
230+ _, self.app = base.setup_urwid_app()
231+ self.original_contents = self.app.get_contents()
232+
233+ def test_dialog_rendering(self):
234+ # The dialog is correctly displayed without additional controls.
235+ ui.show_dialog(self.app, 'my title', 'my message')
236+ contents = self.app.get_contents()
237+ self.assertIsNot(contents, self.original_contents)
238+ title_widget, message_widget, buttons = cli_helpers.inspect_dialog(
239+ contents)
240+ self.assertEqual('my title', title_widget.text)
241+ self.assertEqual('my message', message_widget.text)
242+ # The dialog only includes the "cancel" button.
243+ self.assertEqual(1, len(buttons))
244+ caption = cli_helpers.get_button_caption(buttons[0])
245+ self.assertEqual('cancel', caption)
246+
247+ def test_dialog_cancel_action(self):
248+ # A dialog can be properly dismissed clicking the cancel button.
249+ ui.show_dialog(self.app, 'my title', 'my message')
250+ contents = self.app.get_contents()
251+ buttons = cli_helpers.inspect_dialog(contents)[2]
252+ cancel_button = buttons[0]
253+ cli_helpers.emit(cancel_button)
254+ # The original contents have been restored.
255+ self.assertIs(self.original_contents, self.app.get_contents())
256+
257+ def test_dialog_customized_actions(self):
258+ # A button is displayed for each customized action provided.
259+ performed = []
260+ actions = [
261+ ('push me', ui.thunk(performed.append, 'push')),
262+ ('pull me', ui.thunk(performed.append, 'pull')),
263+ ]
264+ ui.show_dialog(self.app, 'my title', 'my message', actions=actions)
265+ contents = self.app.get_contents()
266+ buttons = cli_helpers.inspect_dialog(contents)[2]
267+ # Three buttons are displayed: "cancel", "push me" and "pull me".
268+ self.assertEqual(3, len(buttons))
269+ captions = [
270+ cli_helpers.get_button_caption(button) for button in buttons]
271+ self.assertEqual(['cancel', 'push me', 'pull me'], captions)
272+ push, pull = buttons[1:]
273+ # Click the "push me" button.
274+ cli_helpers.emit(push)
275+ self.assertEqual(['push'], performed)
276+ # Click the "pull me" button two times.
277+ cli_helpers.emit(pull)
278+ cli_helpers.emit(pull)
279+ self.assertEqual(['push', 'pull', 'pull'], performed)
280+
281+ def test_not_dismissable(self):
282+ # The cancel button is not present if the dialog is not dismissable.
283+ actions = [('push me', lambda *args: None)]
284+ ui.show_dialog(
285+ self.app, 'my title', 'my message',
286+ actions=actions, dismissable=False)
287+ contents = self.app.get_contents()
288+ buttons = cli_helpers.inspect_dialog(contents)[2]
289+ # Only the "push me" button is displayed.
290+ self.assertEqual(1, len(buttons))
291+ caption = cli_helpers.get_button_caption(buttons[0])
292+ self.assertEqual('push me', caption)
293+
294+
295 class TestThunk(unittest.TestCase):
296
297 widget = 'test-widget'
298
299=== modified file 'quickstart/tests/cli/test_views.py'
300--- quickstart/tests/cli/test_views.py 2013-12-19 10:59:57 +0000
301+++ quickstart/tests/cli/test_views.py 2013-12-20 10:30:54 +0000
302@@ -119,19 +119,6 @@
303 """
304 return lambda arg: isinstance(arg, cls)
305
306- def get_button_caption(self, button):
307- """Return the button caption as a string."""
308- return button._w.original_widget.text
309-
310- def emit(self, widget):
311- """Emit the first signal associated withe the given widget.
312-
313- This is usually invoked to click buttons.
314- """
315- # Retrieve the first signal name (usually is 'click').
316- signal_name = widget.signals[0]
317- urwid.emit_signal(widget, signal_name, widget)
318-
319
320 class TestEnvIndex(EnvViewTestsMixin, unittest.TestCase):
321
322@@ -177,10 +164,11 @@
323 env_data = envs.get_env_data(env_db, env_name)
324 # The caption includes the environment description.
325 env_description = envs.get_env_short_description(env_data)
326- self.assertIn(env_description, self.get_button_caption(button))
327+ self.assertIn(
328+ env_description, cli_helpers.get_button_caption(button))
329 # When the button is clicked, the detail view is called passing the
330 # corresponding environment data.
331- self.emit(button)
332+ cli_helpers.emit(button)
333 mock_env_detail.assert_called_once_with(
334 self.app, self.env_type_db, env_db, self.save_callable,
335 env_data)
336@@ -197,7 +185,8 @@
337 contents = self.app.get_contents()
338 focused_widget = contents.get_focus()[0]
339 self.assertIsInstance(focused_widget, ui.MenuButton)
340- self.assertIn(env_description, self.get_button_caption(focused_widget))
341+ self.assertIn(
342+ env_description, cli_helpers.get_button_caption(focused_widget))
343
344 def test_status_with_errors(self):
345 # The status message explains how errors are displayed.
346@@ -272,35 +261,36 @@
347 self.assertEqual(expected_text, widget.text)
348
349 def test_view_buttons(self):
350- # The following buttons are displayed: "back", "use" and "set default".
351+ # The following buttons are displayed: "back", "use", "set default" and
352+ # "remove".
353 self.call_view(env_name='ec2-west')
354 buttons = self.get_control_buttons()
355- captions = [self.get_button_caption(button) for button in buttons]
356- self.assertEqual(['back', 'use', 'set default'], captions)
357+ captions = map(cli_helpers.get_button_caption, buttons)
358+ self.assertEqual(['back', 'use', 'set default', 'remove'], captions)
359
360 def test_view_buttons_default(self):
361 # If the environment is the default one, the "set default" button is
362- # not displayed. The buttons we expect are "back" and "use".
363+ # not displayed. The buttons we expect are "back", "use" and "remove".
364 self.call_view(env_name='lxc')
365 buttons = self.get_control_buttons()
366- captions = [self.get_button_caption(button) for button in buttons]
367- self.assertEqual(['back', 'use'], captions)
368+ captions = map(cli_helpers.get_button_caption, buttons)
369+ self.assertEqual(['back', 'use', 'remove'], captions)
370
371 def test_view_buttons_error(self):
372 # If the environment is not valid, the "use" button is not displayed.
373- # The buttons we expect are "back" and "set default".
374+ # The buttons we expect are "back", "set default" and "remove".
375 self.call_view(env_name='local-with-errors')
376 buttons = self.get_control_buttons()
377- captions = [self.get_button_caption(button) for button in buttons]
378- self.assertEqual(['back', 'set default'], captions)
379+ captions = map(cli_helpers.get_button_caption, buttons)
380+ self.assertEqual(['back', 'set default', 'remove'], captions)
381
382 @mock.patch('quickstart.cli.views.env_index')
383 def test_back_button(self, mock_env_index):
384 # The index view is called if the "back" button is clicked.
385 self.call_view(env_name='ec2-west')
386- # The control buttons are: "back", "use" and "set default".
387+ # The "back" button is the first one.
388 back_button = self.get_control_buttons()[0]
389- self.emit(back_button)
390+ cli_helpers.emit(back_button)
391 mock_env_index.assert_called_once_with(
392 self.app, self.env_type_db, self.env_db, self.save_callable)
393
394@@ -308,10 +298,10 @@
395 # The application exits if the "use" button is clicked.
396 # The env_db and the current environment data are returned.
397 self.call_view(env_name='ec2-west')
398- # The control buttons are: "back", "use" and "set default".
399+ # The "use" button is the second one.
400 use_button = self.get_control_buttons()[1]
401 with self.assertRaises(ui.AppExit) as context_manager:
402- self.emit(use_button)
403+ cli_helpers.emit(use_button)
404 expected_return_value = (self.env_db, self.env_data)
405 self.assertEqual(
406 expected_return_value, context_manager.exception.return_value)
407@@ -321,9 +311,9 @@
408 # The current environment is set as default if the "set default" button
409 # is clicked. Subsequently the application switches to the index view.
410 self.call_view(env_name='ec2-west')
411- # The control buttons are: "back", "use" and "set default".
412+ # The "set default" button is the third one.
413 set_default_button = self.get_control_buttons()[2]
414- self.emit(set_default_button)
415+ cli_helpers.emit(set_default_button)
416 # The index view has been called passing the modified env_db as third
417 # argument.
418 self.assertTrue(mock_env_index.called)
419@@ -333,6 +323,64 @@
420 # The new env_db has been saved.
421 self.save_callable.assert_called_once_with(new_env_db)
422
423+ def test_remove_button(self):
424+ # A confirmation dialog is displayed if the "remove" button is clicked.
425+ self.call_view(env_name='ec2-west')
426+ original_contents = self.app.get_contents()
427+ # The "remove" button is the last one.
428+ remove_button = self.get_control_buttons()[-1]
429+ cli_helpers.emit(remove_button)
430+ # The original env detail contents have been replaced.
431+ contents = self.app.get_contents()
432+ self.assertIsNot(contents, original_contents)
433+ # A "remove" confirmation dialog is displayed.
434+ title_widget, message_widget, buttons = cli_helpers.inspect_dialog(
435+ contents)
436+ self.assertEqual('Remove the ec2-west environment', title_widget.text)
437+ self.assertEqual('This action cannot be undone!', message_widget.text)
438+ # The dialog includes the "cancel" and "confirm" buttons.
439+ self.assertEqual(2, len(buttons))
440+ captions = map(cli_helpers.get_button_caption, buttons)
441+ self.assertEqual(['cancel', 'confirm'], captions)
442+
443+ def test_remove_cancelled(self):
444+ # The "remove" confirmation dialog can be safely dismissed.
445+ self.call_view(env_name='ec2-west')
446+ original_contents = self.app.get_contents()
447+ # The "remove" button is the last one.
448+ remove_button = self.get_control_buttons()[-1]
449+ cli_helpers.emit(remove_button)
450+ contents = self.app.get_contents()
451+ buttons = cli_helpers.inspect_dialog(contents)[2]
452+ # The "cancel" button is the first one in the dialog.
453+ cancel_button = buttons[0]
454+ cli_helpers.emit(cancel_button)
455+ # The original contents have been restored.
456+ self.assertIs(original_contents, self.app.get_contents())
457+
458+ @mock.patch('quickstart.cli.views.env_index')
459+ def test_remove_confirmed(self, mock_env_index):
460+ # The current environment is removed if the "remove" button is clicked
461+ # and then the deletion is confirmed. Subsequently the application
462+ # switches to the index view.
463+ self.call_view(env_name='ec2-west')
464+ # The "remove" button is the last one.
465+ remove_button = self.get_control_buttons()[-1]
466+ cli_helpers.emit(remove_button)
467+ contents = self.app.get_contents()
468+ buttons = cli_helpers.inspect_dialog(contents)[2]
469+ # The "confirm" button is the second one in the dialog.
470+ confirm_button = buttons[1]
471+ cli_helpers.emit(confirm_button)
472+ # The index view has been called passing the modified env_db as third
473+ # argument.
474+ self.assertTrue(mock_env_index.called)
475+ new_env_db = mock_env_index.call_args[0][2]
476+ # The new env_db no longer includes the "ec2-west" environment.
477+ self.assertNotIn('ec2-west', new_env_db['environments'])
478+ # The new env_db has been saved.
479+ self.save_callable.assert_called_once_with(new_env_db)
480+
481 def test_status_with_errors(self):
482 # The status message explains how field errors are displayed.
483 self.call_view(env_name='local-with-errors')
484
485=== modified file 'quickstart/tests/models/test_envs.py'
486--- quickstart/tests/models/test_envs.py 2013-12-17 15:30:48 +0000
487+++ quickstart/tests/models/test_envs.py 2013-12-20 10:30:54 +0000
488@@ -225,7 +225,7 @@
489 self.assertIn('new: contents', contents)
490
491
492-class GetSetEnvDataTestsMixin(object):
493+class EnvDataTestsMixin(object):
494 """Set up an initial environments dictionary."""
495
496 def setUp(self):
497@@ -245,7 +245,7 @@
498 },
499 }
500 self.original = copy.deepcopy(self.env_db)
501- super(GetSetEnvDataTestsMixin, self).setUp()
502+ super(EnvDataTestsMixin, self).setUp()
503
504 def assert_env_db_not_modified(self):
505 """Ensure the stored env_db is not modified."""
506@@ -253,8 +253,7 @@
507
508
509 class TestGetEnvData(
510- GetSetEnvDataTestsMixin, helpers.ValueErrorTestsMixin,
511- unittest.TestCase):
512+ EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
513
514 def test_env_not_found(self):
515 # A ValueError is raised if an environment with the given name is not
516@@ -301,8 +300,7 @@
517
518
519 class TestSetEnvData(
520- GetSetEnvDataTestsMixin, helpers.ValueErrorTestsMixin,
521- unittest.TestCase):
522+ EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
523
524 def test_error_no_name_key(self):
525 # A ValueError is raised if the env_data dictionary does not include
526@@ -469,6 +467,36 @@
527 self.assertNotIn('default', self.env_db)
528
529
530+class TestRemoveEnv(
531+ EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
532+
533+ def test_removal(self):
534+ # An environment is successfully removed from the environments db.
535+ envs.remove_env(self.env_db, 'aws')
536+ environments = self.env_db['environments']
537+ self.assertNotIn('aws', environments)
538+ # The other environments are not removed.
539+ self.assertIn('lxc', environments)
540+ # The default environment is not modified.
541+ self.assertEqual('lxc', self.env_db['default'])
542+
543+ def test_default_environment_removal(self):
544+ # An environment is successfully removed even if it is the default one.
545+ envs.remove_env(self.env_db, 'lxc')
546+ environments = self.env_db['environments']
547+ self.assertNotIn('lxc', environments)
548+ # The other environments are not removed.
549+ self.assertIn('aws', environments)
550+ # The environments database no longer includes a default environment.
551+ self.assertNotIn('default', self.env_db)
552+
553+ def test_invalid_environment_name(self):
554+ # A ValueError is raised if the environment is not present in env_db.
555+ expected = "the environment named u'no-such' does not exist"
556+ with self.assert_value_error(expected):
557+ envs.remove_env(self.env_db, 'no-such')
558+
559+
560 class TestGetEnvTypeDb(unittest.TestCase):
561
562 def setUp(self):

Subscribers

People subscribed via source and target branches