Skip to content

Commit

Permalink
Allow more batch actions in the add-on store (remove, enable and disa…
Browse files Browse the repository at this point in the history
…ble) (#15646)

Closes #15623

Summary of the issue:
In the store, install action can be executed on multiple add-ons. Other batch actions are needed.

Description of user facing changes
The following additional batch actions are now available in the store:

Remove selected add-ons
Update selected add-ons
Enable selected add-ons
Disable selected add-ons

Description of development approach
Use the same condition as the one required for the corresponding single action to occur.
For code simplicity there is no distinction between Enable and Enable (override) for the corresponding multiple action. Anyway, when an add-on should override compatibility to be enabled, there is a confirmation dialog box.
Also for simplicity, there is no multiple replace action:
if a multiple selection contains installable add-on with or without updatable or replaceable add-ons, the "Install selected add-ons" batch action will be available
If a multiple selection contains updatable add-ons, replaceable add-ons or both types, the "Update selected add-ons" batch action will be available.
For batch actions requiring a confirmation (e.g. override incompatibility, uninstall), the confirmation dialog box has a "Remember this choice" checkbox so that the user can answer yes or no for all the same questions.
When many add-ons are selected and a batch action is applied to them, the action actually applies only to the add-ons for which it is appropriate. E.g batch enable will not be applied to already enabled add-ons.
  • Loading branch information
CyrilleB79 committed Nov 27, 2023
1 parent 0573e3b commit 7a328bd
Show file tree
Hide file tree
Showing 6 changed files with 378 additions and 81 deletions.
86 changes: 82 additions & 4 deletions source/gui/addonStoreGui/controls/actions.py
@@ -1,5 +1,5 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2022-2023 NV Access Limited
# Copyright (C) 2022-2023 NV Access Limited, Cyrille Bougot
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

Expand Down Expand Up @@ -77,15 +77,15 @@ def _populateContextMenu(self):
elif isMenuItemInContextMenu:
# The action is invalid but the menu item exists and is in the context menu.
# Remove the menu item from the context menu.
self._contextMenu.RemoveItem(menuItem)
self._contextMenu.Remove(menuItem)
del self._actionMenuItemMap[action]

menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems())
for menuItem in menuItems:
if menuItem not in self._actionMenuItemMap.values():
# The menu item is not in the action menu item map.
# It should be removed from the context menu.
self._contextMenu.RemoveItem(menuItem)
self._contextMenu.Remove(menuItem)


class _MonoActionsContextMenu(_ActionsContextMenuP[AddonActionVM]):
Expand Down Expand Up @@ -140,7 +140,85 @@ def _actions(self) -> List[BatchAddonActionVM]:
# Translators: Label for an action that installs the selected add-ons
displayName=pgettext("addonStore", "&Install selected add-ons"),
actionHandler=self._storeVM.getAddons,
validCheck=lambda aVMs: self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE,
validCheck=lambda aVMs: (
self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE
and AddonListValidator(aVMs).canUseInstallAction()
),
actionTarget=self._selectedAddons
),
BatchAddonActionVM(
# Translators: Label for an action that updates the selected add-ons
displayName=pgettext("addonStore", "&Update selected add-ons"),
actionHandler=self._storeVM.getAddons,
validCheck=lambda aVMs: AddonListValidator(aVMs).canUseUpdateAction(),
actionTarget=self._selectedAddons
),
BatchAddonActionVM(
# Translators: Label for an action that removes the selected add-ons
displayName=pgettext("addonStore", "&Remove selected add-ons"),
actionHandler=self._storeVM.removeAddons,
validCheck=lambda aVMs: (
self._storeVM._filteredStatusKey in [
# Removing add-ons in the updatable view fails,
# as the updated version cannot be removed.
_StatusFilterKey.INSTALLED,
_StatusFilterKey.INCOMPATIBLE,
]
and AddonListValidator(aVMs).canUseRemoveAction()
),
actionTarget=self._selectedAddons
),
BatchAddonActionVM(
# Translators: Label for an action that enables the selected add-ons
displayName=pgettext("addonStore", "&Enable selected add-ons"),
actionHandler=self._storeVM.enableAddons,
validCheck=lambda aVMs: AddonListValidator(aVMs).canUseEnableAction(),
actionTarget=self._selectedAddons
),
BatchAddonActionVM(
# Translators: Label for an action that disables the selected add-ons
displayName=pgettext("addonStore", "&Disable selected add-ons"),
actionHandler=self._storeVM.disableAddons,
validCheck=lambda aVMs: AddonListValidator(aVMs).canUseDisableAction(),
actionTarget=self._selectedAddons
),
]


class AddonListValidator:
def __init__(self, addonsList: List[AddonListItemVM]):
self.addonsList = addonsList

def canUseInstallAction(self) -> bool:
for aVM in self.addonsList:
if aVM.canUseInstallAction() or aVM.canUseInstallOverrideIncompatibilityAction():
return True
return False

def canUseUpdateAction(self) -> bool:
hasUpdatable = False
hasInstallable = False
for aVM in self.addonsList:
if aVM.canUseUpdateAction() or aVM.canUseReplaceAction():
hasUpdatable = True
if aVM.canUseInstallAction() or aVM.canUseInstallOverrideIncompatibilityAction():
hasInstallable = True
return hasUpdatable and not hasInstallable

def canUseRemoveAction(self) -> bool:
for aVM in self.addonsList:
if aVM.canUseRemoveAction():
return True
return False

def canUseEnableAction(self) -> bool:
for aVM in self.addonsList:
if aVM.canUseEnableOverrideIncompatibilityAction() or aVM.canUseEnableAction():
return True
return False

def canUseDisableAction(self) -> bool:
for aVM in self.addonsList:
if aVM.canUseDisableAction():
return True
return False
99 changes: 67 additions & 32 deletions source/gui/addonStoreGui/controls/messageDialogs.py
@@ -1,5 +1,5 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2023 NV Access Limited
# Copyright (C) 2023 NV Access Limited, Cyrille Bougot
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

Expand Down Expand Up @@ -32,6 +32,10 @@


class ErrorAddonInstallDialogWithYesNoButtons(ErrorAddonInstallDialog):
def __init__(self, *args, useRememberChoiceCheckbox: bool = False, **kwargs):
self.useRememberChoiceCheckbox = useRememberChoiceCheckbox
super().__init__(*args, **kwargs)

def _addButtons(self, buttonHelper: ButtonHelper) -> None:
addonInfoButton = buttonHelper.addButton(
self,
Expand All @@ -58,11 +62,27 @@ def _addButtons(self, buttonHelper: ButtonHelper) -> None:
noButton.SetDefault()
noButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.NO))

def _addContents(self, contentsSizer: BoxSizerHelper):
if self.useRememberChoiceCheckbox:
self.rememberChoiceCheckbox = wx.CheckBox(
self,
# Translators: A checkbox in the dialog to remember the choice made when installing or enabling
# incompatible add-ons, or when removing add-ons.
label=pgettext("addonStore", "Remember this choice for subsequent add-ons"),
)
contentsSizer.addItem(self.rememberChoiceCheckbox)

def shouldRememberChoice(self) -> bool:
if self.useRememberChoiceCheckbox:
return self.rememberChoiceCheckbox.IsChecked()
return False


def _shouldProceedWhenInstalledAddonVersionUnknown(
parent: wx.Window,
addon: _AddonGUIModel
) -> bool:
addon: _AddonGUIModel,
useRememberChoiceCheckbox: bool = False,
) -> tuple[bool, bool]:
# an installed add-on should have an addon Handler Model
assert addon._addonHandlerModel
incompatibleMessage = pgettext(
Expand All @@ -81,37 +101,47 @@ def _shouldProceedWhenInstalledAddonVersionUnknown(
lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion),
NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT)
)
res = displayDialogAsModal(ErrorAddonInstallDialogWithYesNoButtons(
dlg = ErrorAddonInstallDialogWithYesNoButtons(
parent=parent,
# Translators: The title of a dialog presented when an error occurs.
title=pgettext("addonStore", "Add-on not compatible"),
message=incompatibleMessage,
showAddonInfoFunction=lambda: _showAddonInfo(addon)
))
return res == wx.YES
showAddonInfoFunction=lambda: _showAddonInfo(addon),
useRememberChoiceCheckbox=useRememberChoiceCheckbox,
)
res = displayDialogAsModal(dlg)
return (res == wx.YES), dlg.shouldRememberChoice()


def _shouldProceedToRemoveAddonDialog(
addon: "SupportsVersionCheck"
) -> bool:
return messageBox(
pgettext(
"addonStore",
# Translators: Presented when attempting to remove the selected add-on.
# {addon} is replaced with the add-on name.
"Are you sure you wish to remove the {addon} add-on from NVDA? "
"This cannot be undone."
).format(addon=addon.name),
parent,
addon: "SupportsVersionCheck",
useRememberChoiceCheckbox: bool = False,
) -> tuple[bool, bool]:
removeMessage = pgettext(
"addonStore",
# Translators: Presented when attempting to remove the selected add-on.
# {addon} is replaced with the add-on name.
"Are you sure you wish to remove the {addon} add-on from NVDA? "
"This cannot be undone."
).format(addon=addon.name)
dlg = ErrorAddonInstallDialogWithYesNoButtons(
parent=parent,
# Translators: Title for message asking if the user really wishes to remove the selected Add-on.
pgettext("addonStore", "Remove Add-on"),
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING
) == wx.YES
title=pgettext("addonStore", "Remove Add-on"),
message=removeMessage,
showAddonInfoFunction=lambda: _showAddonInfo(addon),
useRememberChoiceCheckbox=useRememberChoiceCheckbox,
)
res = displayDialogAsModal(dlg)
return (res == wx.YES), dlg.shouldRememberChoice()


def _shouldInstallWhenAddonTooOldDialog(
parent: wx.Window,
addon: _AddonGUIModel
) -> bool:
addon: _AddonGUIModel,
useRememberChoiceCheckbox: bool = False,
) -> tuple[bool, bool]:
incompatibleMessage = pgettext(
"addonStore",
# Translators: The message displayed when installing an add-on package that is incompatible
Expand All @@ -128,20 +158,23 @@ def _shouldInstallWhenAddonTooOldDialog(
lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion),
NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT)
)
res = displayDialogAsModal(ErrorAddonInstallDialogWithYesNoButtons(
dlg = ErrorAddonInstallDialogWithYesNoButtons(
parent=parent,
# Translators: The title of a dialog presented when an error occurs.
title=pgettext("addonStore", "Add-on not compatible"),
message=incompatibleMessage,
showAddonInfoFunction=lambda: _showAddonInfo(addon)
))
return res == wx.YES
showAddonInfoFunction=lambda: _showAddonInfo(addon),
useRememberChoiceCheckbox=useRememberChoiceCheckbox,
)
res = displayDialogAsModal(dlg)
return (res == wx.YES), dlg.shouldRememberChoice()


def _shouldEnableWhenAddonTooOldDialog(
parent: wx.Window,
addon: _AddonGUIModel
) -> bool:
addon: _AddonGUIModel,
useRememberChoiceCheckbox: bool = False,
) -> tuple[bool, bool]:
incompatibleMessage = pgettext(
"addonStore",
# Translators: The message displayed when enabling an add-on package that is incompatible
Expand All @@ -158,14 +191,16 @@ def _shouldEnableWhenAddonTooOldDialog(
lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion),
NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT)
)
res = displayDialogAsModal(ErrorAddonInstallDialogWithYesNoButtons(
dlg = ErrorAddonInstallDialogWithYesNoButtons(
parent=parent,
# Translators: The title of a dialog presented when an error occurs.
title=pgettext("addonStore", "Add-on not compatible"),
message=incompatibleMessage,
showAddonInfoFunction=lambda: _showAddonInfo(addon)
))
return res == wx.YES
showAddonInfoFunction=lambda: _showAddonInfo(addon),
useRememberChoiceCheckbox=useRememberChoiceCheckbox,
)
res = displayDialogAsModal(dlg)
return (res == wx.YES), dlg.shouldRememberChoice()


def _showAddonInfo(addon: _AddonGUIModel) -> None:
Expand Down
36 changes: 36 additions & 0 deletions source/gui/addonStoreGui/viewModels/addonList.py
Expand Up @@ -124,6 +124,42 @@ def status(self, newStatus: AvailableAddonStatus):
def Id(self) -> str:
return self._model.listItemVMId

def canUseInstallAction(self) -> bool:
return self.status == AvailableAddonStatus.AVAILABLE

def canUseInstallOverrideIncompatibilityAction(self) -> bool:
return self.status == AvailableAddonStatus.INCOMPATIBLE and self.model.canOverrideCompatibility

def canUseUpdateAction(self) -> bool:
return self.status == AvailableAddonStatus.UPDATE

def canUseReplaceAction(self) -> bool:
return self.status == AvailableAddonStatus.REPLACE_SIDE_LOAD

def canUseRemoveAction(self) -> bool:
return (
self.model.isInstalled
and self.status != AvailableAddonStatus.PENDING_REMOVE
)

def canUseEnableAction(self) -> bool:
return self.status == AvailableAddonStatus.DISABLED or self.status == AvailableAddonStatus.PENDING_DISABLE

def canUseEnableOverrideIncompatibilityAction(self) -> bool:
return self.status in (
AvailableAddonStatus.INCOMPATIBLE_DISABLED,
AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED,
) and self.model.canOverrideCompatibility

def canUseDisableAction(self) -> bool:
return self.model.isInstalled and self.status not in (
AvailableAddonStatus.DISABLED,
AvailableAddonStatus.PENDING_DISABLE,
AvailableAddonStatus.INCOMPATIBLE_DISABLED,
AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED,
AvailableAddonStatus.PENDING_REMOVE,
)

def __repr__(self) -> str:
return f"{self.__class__.__name__}: {self.Id}, {self.status}"

Expand Down

0 comments on commit 7a328bd

Please sign in to comment.