console_conf/chooser: UI tweaks

- drop the ABORT button
- introduce BACK button, goes back to the correct previous screen
- sort actions in the order of run -> recover -> install -> rest
- capitalize action title strings
- simplify current model actions screen
- user hints in the confirm screen for well known modes
  - generic hint for unknown modes

Signed-off-by: Maciej Borzecki <maciej.zenon.borzecki@canonical.com>
This commit is contained in:
Maciej Borzecki 2020-04-07 17:38:45 +02:00
parent d3ac0d6f36
commit a957c5b889
5 changed files with 244 additions and 113 deletions

View File

@ -26,6 +26,7 @@ log = logging.getLogger("console_conf.controllers.chooser")
class RecoveryChooserBaseController(BaseController): class RecoveryChooserBaseController(BaseController):
def __init__(self, app): def __init__(self, app):
super().__init__(app) super().__init__(app)
self.model = app.base_model self.model = app.base_model
@ -34,25 +35,57 @@ class RecoveryChooserBaseController(BaseController):
# exit without taking any action # exit without taking any action
self.app.exit() self.app.exit()
def back(self):
self.app.prev_sceen()
class RecoveryChooserController(RecoveryChooserBaseController): class RecoveryChooserController(RecoveryChooserBaseController):
def __init__(self, app):
super().__init__(app)
self._model_view, self._all_view = self._make_views()
# one of the current views
self._current_view = None
def start_ui(self): def start_ui(self):
# current view is preserved, so going back comes back to the right
# screen
if self._current_view is None:
# right off the start, default to all-systems view
self._current_view = self._all_view
# unless we can show the current model
if self._model_view is not None:
self._current_view = self._model_view
self.ui.set_body(self._current_view)
def _make_views(self):
current_view = None
if self.model.current and self.model.current.actions: if self.model.current and self.model.current.actions:
# only when we have a current system and it has actions available # only when we have a current system and it has actions available
more = len(self.model.systems) > 1 more = len(self.model.systems) > 1
view = ChooserCurrentSystemView(self, self.model.current, current_view = ChooserCurrentSystemView(self,
self.model.current,
has_more=more) has_more=more)
else:
view = ChooserView(self, self.model.systems) all_view = ChooserView(self, self.model.systems)
self.ui.set_body(view) return current_view, all_view
def select(self, system, action): def select(self, system, action):
self.model.select(system, action) self.model.select(system, action)
self.app.next_screen() self.app.next_screen()
def more_options(self): def more_options(self):
self.ui.set_body(ChooserView(self, self.model.systems)) self._current_view = self._all_view
self.ui.set_body(self._all_view)
def back(self):
if self._current_view == self._all_view and \
self._model_view is not None:
# back in the all-systems screen goes back to the current model
# screen, provided we have one
self._current_view = self._model_view
self.ui.set_body(self._current_view)
class RecoveryChooserConfirmController(RecoveryChooserBaseController): class RecoveryChooserConfirmController(RecoveryChooserBaseController):
@ -65,3 +98,7 @@ class RecoveryChooserConfirmController(RecoveryChooserBaseController):
# output the choice # output the choice
self.app.respond(self.model.selection) self.app.respond(self.model.selection)
self.app.exit() self.app.exit()
def back(self):
self.model.unselect()
self.app.prev_screen()

View File

@ -34,14 +34,18 @@ class MockedApplication:
opts = None opts = None
def make_app(): def make_app(model=None):
app = MockedApplication() app = MockedApplication()
app.ui = mock.Mock() app.ui = mock.Mock()
if model is not None:
app.base_model = model
else:
app.base_model = mock.Mock() app.base_model = mock.Mock()
app.context = Context.new(app) app.context = Context.new(app)
app.exit = mock.Mock() app.exit = mock.Mock()
app.respond = mock.Mock() app.respond = mock.Mock()
app.next_screen = mock.Mock() app.next_screen = mock.Mock()
app.prev_screen = mock.Mock()
return app return app
@ -92,12 +96,15 @@ def make_model():
class TestChooserConfirmController(unittest.TestCase): class TestChooserConfirmController(unittest.TestCase):
def test_abort(self): def test_back(self):
app = make_app() app = make_app()
c = RecoveryChooserConfirmController(app) c = RecoveryChooserConfirmController(app)
c.cancel() c.model = mock.Mock(selection='selection')
c.back()
app.respond.assert_not_called() app.respond.assert_not_called()
app.exit.assert_called() app.exit.assert_not_called()
app.prev_screen.assert_called()
c.model.unselect.assert_called()
def test_confirm(self): def test_confirm(self):
app = make_app() app = make_app()
@ -119,17 +126,9 @@ class TestChooserConfirmController(unittest.TestCase):
class TestChooserController(unittest.TestCase): class TestChooserController(unittest.TestCase):
def test_abort(self):
app = make_app()
c = RecoveryChooserController(app)
c.cancel()
app.respond.assert_not_called()
app.exit.assert_called()
def test_select(self): def test_select(self):
app = make_app() app = make_app(model=make_model())
c = RecoveryChooserController(app) c = RecoveryChooserController(app)
c.model = make_model()
c.select(c.model.systems[0], c.model.systems[0].actions[0]) c.select(c.model.systems[0], c.model.systems[0].actions[0])
exp = SelectedSystemAction(system=c.model.systems[0], exp = SelectedSystemAction(system=c.model.systems[0],
@ -140,50 +139,84 @@ class TestChooserController(unittest.TestCase):
app.respond.assert_not_called() app.respond.assert_not_called()
app.exit.assert_not_called() app.exit.assert_not_called()
@mock.patch('console_conf.controllers.chooser.ChooserCurrentSystemView') @mock.patch('console_conf.controllers.chooser.ChooserCurrentSystemView',
@mock.patch('console_conf.controllers.chooser.ChooserView') return_value='current')
@mock.patch('console_conf.controllers.chooser.ChooserView',
return_value='all')
def test_current_ui_first(self, cv, ccsv): def test_current_ui_first(self, cv, ccsv):
app = make_app() app = make_app(model=make_model())
c = RecoveryChooserController(app) c = RecoveryChooserController(app)
c.model = make_model() c.ui.start_ui = mock.Mock()
c.start_ui()
# current system view is constructed # current system view is constructed
ccsv.assert_called_with(c, c.model.current, has_more=True) ccsv.assert_called_with(c, c.model.current, has_more=True)
# but the all systems view is not # as well as all systems view
cv.assert_not_called() cv.assert_called_with(c, c.model.systems)
ccsv.reset_mock() c.start_ui()
c.ui.set_body.assert_called_with('current')
# user selects more options and the view is replaced # user selects more options and the view is replaced
c.more_options() c.more_options()
# we get the all systems view now c.ui.set_body.assert_called_with('all')
cv.assert_called_with(c, c.model.systems)
# and the current system view was not constructed
ccsv.assert_not_called()
@mock.patch('console_conf.controllers.chooser.ChooserCurrentSystemView') @mock.patch('console_conf.controllers.chooser.ChooserCurrentSystemView',
@mock.patch('console_conf.controllers.chooser.ChooserView') return_value='current')
def test_only_one_and_current(self, cv, ccsv): @mock.patch('console_conf.controllers.chooser.ChooserView',
app = make_app() return_value='all')
def test_current_current_all_there_and_back(self, cv, ccsv):
app = make_app(model=make_model())
c = RecoveryChooserController(app) c = RecoveryChooserController(app)
c.model = RecoverySystemsModel.from_systems([model2_current]) c.ui.start_ui = mock.Mock()
# sanity
ccsv.assert_called_with(c, c.model.current, has_more=True)
cv.assert_called_with(c, c.model.systems)
c.start_ui() c.start_ui()
# current system view is constructed c.ui.set_body.assert_called_with('current')
ccsv.assert_called_with(c, c.model.current, has_more=False) # user selects more options and the view is replaced
# but the all systems view is not c.more_options()
cv.assert_not_called() c.ui.set_body.assert_called_with('all')
# go back now
c.back()
c.ui.set_body.assert_called_with('current')
# nothing
c.back()
c.ui.set_body.not_called()
@mock.patch('console_conf.controllers.chooser.ChooserCurrentSystemView') @mock.patch('console_conf.controllers.chooser.ChooserCurrentSystemView',
@mock.patch('console_conf.controllers.chooser.ChooserView') return_value='current')
def test_all_systems_first_no_current(self, cv, ccsv): @mock.patch('console_conf.controllers.chooser.ChooserView',
app = make_app() return_value='all')
def test_only_one_and_current(self, cv, ccsv):
model = RecoverySystemsModel.from_systems([model2_current])
app = make_app(model=model)
c = RecoveryChooserController(app) c = RecoveryChooserController(app)
c.model = RecoverySystemsModel.from_systems([model1_non_current]) c.ui.start_ui = mock.Mock()
# both views are constructed
ccsv.assert_called_with(c, c.model.current, has_more=False)
cv.assert_called_with(c, c.model.systems)
c.start_ui()
c.ui.set_body.assert_called_with('current')
# going back does nothing
c.back()
c.ui.set_body.not_called()
@mock.patch('console_conf.controllers.chooser.ChooserCurrentSystemView',
return_value='current')
@mock.patch('console_conf.controllers.chooser.ChooserView',
return_value='all')
def test_all_systems_first_no_current(self, cv, ccsv):
model = RecoverySystemsModel.from_systems([model1_non_current])
app = make_app(model=model)
c = RecoveryChooserController(app)
c.ui.start_ui = mock.Mock()
# sanity # sanity
self.assertIsNone(c.model.current) self.assertIsNone(c.model.current)
c.start_ui() # we get the all-systems view now
# we get the all systems view now
cv.assert_called() cv.assert_called()
# current system view is not constructed # current system view is not constructed at all
ccsv.assert_not_called() ccsv.assert_not_called()
c.start_ui()
c.ui.set_body.assert_called_with('all')

View File

@ -26,8 +26,8 @@ from urwid import (
) )
from subiquitycore.ui.buttons import ( from subiquitycore.ui.buttons import (
danger_btn, danger_btn,
reset_btn,
forward_btn, forward_btn,
back_btn,
) )
from subiquitycore.ui.actionmenu import ( from subiquitycore.ui.actionmenu import (
Action, Action,
@ -47,23 +47,36 @@ from subiquitycore.view import BaseView
log = logging.getLogger("console_conf.views.chooser") log = logging.getLogger("console_conf.views.chooser")
class ChooserCurrentSystemView(BaseView): class ChooserBaseView(BaseView):
title = "Ubuntu Core" title = "Ubuntu Core"
def __init__(self, controller, current, has_more=False): def __init__(self, current, scr):
fmt = "Select one of available actions for \"{}\" by \"{}\"{}." if current is not None:
maybe_more = " or view all available systems" if has_more else "" self.title = current.model.display_name
excerpt = fmt.format(current.model.display_name,
current.brand.display_name,
maybe_more)
super().__init__(scr)
def by_preferred_action_type(action):
"""Order action entries by having the 'run' mode first, then 'recover', then
'install', the rest is ordered alphabetically."""
return (action.mode != "run",
action.mode != "recover",
action.mode != "install",
action.title.lower())
class ChooserCurrentSystemView(ChooserBaseView):
excerpt = "Select action:"
def __init__(self, controller, current, has_more=False):
self.controller = controller self.controller = controller
log.debug('current system: %s', current)
log.debug('more systems available: %s', has_more) log.debug('more systems available: %s', has_more)
log.debug('current system: %s', current)
actions = [] actions = []
for action in current.actions: for action in sorted(current.actions, key=by_preferred_action_type):
actions.append(forward_btn(label=action.title, actions.append(forward_btn(label=action.title.capitalize(),
on_press=self._current_system_action, on_press=self._current_system_action,
user_arg=(current, action))) user_arg=(current, action)))
@ -75,16 +88,11 @@ class ChooserCurrentSystemView(BaseView):
lb = ListBox(actions) lb = ListBox(actions)
buttons = [ super().__init__(current,
reset_btn("ABORT", on_press=self.abort), screen(
]
super().__init__(screen(
lb, lb,
buttons=button_pile(buttons),
focus_buttons=False,
narrow_rows=True, narrow_rows=True,
excerpt=excerpt)) excerpt=self.excerpt))
def _current_system_action(self, sender, arg): def _current_system_action(self, sender, arg):
current, action = arg current, action = arg
@ -93,12 +101,11 @@ class ChooserCurrentSystemView(BaseView):
def _more_options(self, sender): def _more_options(self, sender):
self.controller.more_options() self.controller.more_options()
def abort(self, result): def back(self, result):
self.controller.cancel() self.controller.back()
class ChooserView(BaseView): class ChooserView(ChooserBaseView):
title = "Ubuntu Core"
excerpt = ("Select one of available recovery systems and a desired " excerpt = ("Select one of available recovery systems and a desired "
"action to execute.") "action to execute.")
@ -123,8 +130,8 @@ class ChooserView(BaseView):
for s in systems: for s in systems:
actions = [] actions = []
log.debug('actions: %s', s.actions) log.debug('actions: %s', s.actions)
for act in s.actions: for act in sorted(s.actions, key=by_preferred_action_type):
actions.append(Action(label=act.title, actions.append(Action(label=act.title.capitalize(),
value=act, value=act,
enabled=True)) enabled=True))
menu = ActionMenu(actions) menu = ActionMenu(actions)
@ -133,7 +140,7 @@ class ChooserView(BaseView):
Text(s.label), Text(s.label),
Text(s.model.display_name), Text(s.model.display_name),
Text(s.brand.display_name), Text(s.brand.display_name),
Text("(current)" if s.current else ""), Text("(installed)" if s.current else ""),
menu, menu,
], menu) ], menu)
trows.append(srow) trows.append(srow)
@ -144,11 +151,13 @@ class ChooserView(BaseView):
Pile([heading_table, systems_table]), Pile([heading_table, systems_table]),
] ]
buttons = [ buttons = []
reset_btn("ABORT", on_press=self.abort), if controller.model.current is not None:
] # back to options of current system
buttons.append(back_btn("BACK", on_press=self.back))
super().__init__(screen( super().__init__(controller.model.current,
screen(
rows=rows, rows=rows,
buttons=button_pile(buttons), buttons=button_pile(buttons),
focus_buttons=False, focus_buttons=False,
@ -157,43 +166,52 @@ class ChooserView(BaseView):
def _system_action(self, sender, action, system): def _system_action(self, sender, action, system):
self.controller.select(system, action) self.controller.select(system, action)
def abort(self, result): def back(self, result):
self.controller.cancel() self.controller.back()
class ChooserConfirmView(BaseView): class ChooserConfirmView(ChooserBaseView):
title = "Ubuntu Core"
excerpt = ("Summary of the selected action.") canned_summary = {
"run": "Continue running the system without any changes.",
"recover": ("You have requested to reboot the system into recovery "
"mode."),
"install": ("You are about to {action} the system version {version} "
"for {model} from {publisher}.\n\n"
"This will remove all existing user data on the "
"device.\n\n"
"The system will reboot in the process."),
}
default_summary = ("You are about to execute action \"{action}\" using "
"system version {version} for device {model} from "
"{publisher}.\n\n"
"Make sure you understand the consequences of doing "
"so.")
def __init__(self, controller, selection): def __init__(self, controller, selection):
self.controller = controller self.controller = controller
buttons = [ buttons = [
danger_btn("CONFIRM", on_press=self.confirm), danger_btn("CONFIRM", on_press=self.confirm),
reset_btn("ABORT", on_press=self.abort), back_btn("BACK", on_press=self.back),
]
using_summary = "System seed of device {} version {} from {}".format(
selection.system.model.display_name,
selection.system.label,
selection.system.brand.display_name
)
summary = [
TableRow([Text("Action:"), Color.info_error(Text(
selection.action.title))]),
TableRow([Text("Using:"), Text(using_summary)]),
] ]
fmt = self.canned_summary.get(selection.action.mode,
self.default_summary)
summary = fmt.format(action=selection.action.title,
model=selection.system.model.display_name,
publisher=selection.system.brand.display_name,
version=selection.system.label)
rows = [ rows = [
Pile([Text("")]), Text(summary),
Pile([TablePile(summary)])
] ]
super().__init__(screen( super().__init__(controller.model.current,
screen(
rows=rows, rows=rows,
buttons=button_pile(buttons), buttons=button_pile(buttons),
focus_buttons=False, focus_buttons=False))
excerpt=self.excerpt))
def abort(self, result):
self.controller.cancel()
def confirm(self, result): def confirm(self, result):
self.controller.confirm() self.controller.confirm()
def back(self, result):
self.controller.back()

View File

View File

@ -0,0 +1,43 @@
# Copyright 2020 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import unittest
from console_conf.models.systems import (
SystemAction,
)
from console_conf.ui.views.chooser import (
by_preferred_action_type,
)
class TestSorting(unittest.TestCase):
def test_simple(self):
data = [
SystemAction(title="b-other", mode="unknown"),
SystemAction(title="reinstall", mode="install"),
SystemAction(title="run normally", mode="run"),
SystemAction(title="a-other", mode="some-other"),
SystemAction(title="recover", mode="recover"),
]
exp = [
SystemAction(title="run normally", mode="run"),
SystemAction(title="recover", mode="recover"),
SystemAction(title="reinstall", mode="install"),
SystemAction(title="a-other", mode="some-other"),
SystemAction(title="b-other", mode="unknown"),
]
self.assertSequenceEqual(sorted(data, key=by_preferred_action_type),
exp)