Merge lp:~frankban/juju-quickstart/env-manage-remove-environment into lp:juju-quickstart
- env-manage-remove-environment
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email:
|
Commit message
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-
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!
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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-
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:/
File quickstart/
https:/
quickstart/
The way you can use this with the dismiss thunk is cool. Feels nice.
https:/
File quickstart/
https:/
quickstart/
ui.thunk(
cool. I really like how this and the show_dialog actions work.
https:/
File quickstart/
https:/
quickstart/
Don't you have this in another base class? If so, maybe this ought to
be a standalone function? it does not require "self."
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Brad Crittenden (bac) wrote : | # |
LGTM
https:/
File quickstart/
https:/
quickstart/
defaults.
If the units are pixels please say so.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
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/
> """
> Remove the private-
> 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:/
> quickstart/
> 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.
- 52. By Francesco Banconi
-
Changes as per review.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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-
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:/
https:/
File quickstart/
https:/
quickstart/
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://
Updated the docstring.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
Thank you both for the reviews!
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
> 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/
>
>
>> """
>> Remove the private-
>
>> 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:/
>> quickstart/
>> 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:/
>
Preview Diff
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): |
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`. app-demo. py).
QA: start the demo app
(`make` and `./cli-
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): cli/ui. py cli/views. py models/ envs.py tests/cli/ helpers. py tests/cli/ test_ui. py tests/cli/ test_views. py tests/models/ test_envs. py
A [revision details]
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/