From 72f5f30da6ca8e8ac0a7664e9cdc6e527a028ed5 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 25 Jul 2025 17:22:18 +0200 Subject: [PATCH] Working on undo redo --- src/components/datagrid_new/db_management.py | 2 +- .../undo_redo/components/UndoRedo.py | 32 +++++++++------ src/components/workflows/commands.py | 17 ++++++++ .../workflows/components/WorkflowDesigner.py | 25 +++++++++-- src/components/workflows/db_management.py | 3 +- tests/test_undo_redo.py | 41 ++++++++++++------- 6 files changed, 86 insertions(+), 34 deletions(-) diff --git a/src/components/datagrid_new/db_management.py b/src/components/datagrid_new/db_management.py index a49e31e..74d98c7 100644 --- a/src/components/datagrid_new/db_management.py +++ b/src/components/datagrid_new/db_management.py @@ -36,7 +36,7 @@ class DataGridDbManager: self._settings_manager.save(self._session, self._get_db_entry(), {}) def _get_db_entry(self): - return f"{DATAGRID_DB_ENTRY}_{self._key}" + return make_safe_id(f"{DATAGRID_DB_ENTRY}_{self._key}") @staticmethod def _key_as_string(key): diff --git a/src/components/undo_redo/components/UndoRedo.py b/src/components/undo_redo/components/UndoRedo.py index 9d21f33..7d0fcdb 100644 --- a/src/components/undo_redo/components/UndoRedo.py +++ b/src/components/undo_redo/components/UndoRedo.py @@ -13,9 +13,10 @@ logger = logging.getLogger("UndoRedoApp") class CommandHistory(ABC): - def __init__(self, name, desc): + def __init__(self, name, desc, owner): self.name = name self.desc = desc + self.owner = owner @abstractmethod def undo(self): @@ -31,38 +32,42 @@ class UndoRedo(BaseComponentSingleton): def __init__(self, session, _id, settings_manager=None, tabs_manager=None): super().__init__(session, _id, settings_manager, tabs_manager) - self.index = 0 + self.index = -1 self.history = [] self._commands = UndoRedoCommandManager(self) def push(self, command: CommandHistory): + self.history = self.history[:self.index + 1] self.history.append(command) self.index += 1 def undo(self): logger.debug(f"Undo command") - if self.index == 0: + if self.index < 0 : logger.debug(f" No command to undo.") return self - self.index -= 1 command = self.history[self.index] logger.debug(f" Undoing command {command.name} ({command.desc})") res = command.undo() + self.index -= 1 return self, res def redo(self): logger.debug("Redo command") - if self.index >= len(self.history): + if self.index == len(self.history) - 1: logger.debug(f" No command to redo.") return self + self.index += 1 command = self.history[self.index] logger.debug(f" Redoing command {command.name} ({command.desc})") res = command.redo() - self.index += 1 return self, res + def refresh(self): + return self.__ft__(oob=True) + def __ft__(self, oob=False): return Div( self._mk_undo(), @@ -74,33 +79,34 @@ class UndoRedo(BaseComponentSingleton): def _mk_undo(self): if self._can_undo(): - command = self.history[self.index - 1] + command = self.history[self.index] return mk_tooltip(mk_icon(icon_undo, size=24, **self._commands.undo()), - "Undo") + f"Undo '{command.name}'.") else: return mk_tooltip(mk_icon(icon_undo, size=24, can_select=False, cls="mmt-btn-disabled"), - "Nothing to undo") + "Nothing to undo.") def _mk_redo(self): if self._can_redo(): + command = self.history[self.index] return mk_tooltip(mk_icon(icon_redo, size=24, **self._commands.redo()), - "Redo") + f"Redo '{command.name}'.") else: return mk_tooltip(mk_icon(icon_redo, size=24, can_select=False, cls="mmt-btn-disabled"), - "Nothing to redo") + "Nothing to redo.") def _can_undo(self): - return self.index > 0 + return self.index >= 0 def _can_redo(self): - return self.index < len(self.history) + return self.index < len(self.history) - 1 diff --git a/src/components/workflows/commands.py b/src/components/workflows/commands.py index 9e718a1..fa4e2d3 100644 --- a/src/components/workflows/commands.py +++ b/src/components/workflows/commands.py @@ -1,6 +1,23 @@ from components.BaseCommandManager import BaseCommandManager +from components.undo_redo.components.UndoRedo import CommandHistory from components.workflows.constants import Routes, ROUTE_ROOT +class AddConnectorCommand(CommandHistory): + + def __init__(self, owner, connector): + super().__init__("Add connector", "Add connector", owner) + self.connector = connector + + def undo(self): + del self.owner.get_state().components[self.connector.id] + self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db + return self.owner.refresh_designer(True) + + def redo(self, oob=True): + self.owner.get_state().components[self.connector.id] = self.connector + self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db + return self.owner.refresh_designer(oob) + class WorkflowsCommandManager(BaseCommandManager): def __init__(self, owner): diff --git a/src/components/workflows/components/WorkflowDesigner.py b/src/components/workflows/components/WorkflowDesigner.py index 17ea7c1..33e6ba8 100644 --- a/src/components/workflows/components/WorkflowDesigner.py +++ b/src/components/workflows/components/WorkflowDesigner.py @@ -7,7 +7,7 @@ from fasthtml.xtend import Script from assets.icons import icon_error from components.BaseComponent import BaseComponent from components.workflows.assets.icons import icon_play, icon_pause, icon_stop -from components.workflows.commands import WorkflowDesignerCommandManager +from components.workflows.commands import WorkflowDesignerCommandManager, AddConnectorCommand from components.workflows.components.WorkflowPlayer import WorkflowPlayer from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \ @@ -16,6 +16,7 @@ from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, from core.instance_manager import InstanceManager from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS from core.utils import get_unique_id, make_safe_id +from utils.ComponentsInstancesHelper import ComponentsInstancesHelper from utils.DbManagementHelper import DbManagementHelper logger = logging.getLogger("WorkflowDesigner") @@ -80,8 +81,17 @@ class WorkflowDesigner(BaseComponent): def set_boundaries(self, boundaries: dict): self._boundaries = boundaries - def refresh_designer(self): - return self._mk_elements() + def get_state(self): + return self._state + + def get_db(self): + return self._db + + def get_key(self): + return self._key + + def refresh_designer(self, oob=False): + return self._mk_canvas(oob) def refresh_properties(self, oob=False): return self._mk_properties(oob) @@ -102,9 +112,16 @@ class WorkflowDesigner(BaseComponent): properties={"processor_name": PROCESSOR_TYPES[component_type][0]} ) + command = AddConnectorCommand(self, component) + undo_redo = ComponentsInstancesHelper.get_undo_redo(self._session) + #undo_redo.push(command) self._state.components[component_id] = component self._db.save_state(self._key, self._state) # update db - return self.refresh_designer() + undo_redo.snapshot("add_component") + return command.redo(), undo_redo.refresh() + # self._state.components[component_id] = component + # self._db.save_state(self._key, self._state) # update db + # return self.refresh_designer() def move_component(self, component_id, x, y): if component_id in self._state.components: diff --git a/src/components/workflows/db_management.py b/src/components/workflows/db_management.py index ab918e3..1938ce9 100644 --- a/src/components/workflows/db_management.py +++ b/src/components/workflows/db_management.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from components.workflows.constants import WORKFLOWS_DB_ENTRY, WORKFLOW_DESIGNER_DB_ENTRY, \ WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, WORKFLOW_DESIGNER_DB_STATE_ENTRY from core.settings_management import SettingsManager +from core.utils import make_safe_id logger = logging.getLogger("WorkflowsSettings") @@ -160,7 +161,7 @@ class WorkflowsDesignerDbManager: @staticmethod def _get_db_entry(key): - return f"{WORKFLOW_DESIGNER_DB_ENTRY}_{key}" + return make_safe_id(f"{WORKFLOW_DESIGNER_DB_ENTRY}_{key}") def save_settings(self, key: str, settings: WorkflowsDesignerSettings): self._settings_manager.put(self._session, diff --git a/tests/test_undo_redo.py b/tests/test_undo_redo.py index a4a5583..b097a2d 100644 --- a/tests/test_undo_redo.py +++ b/tests/test_undo_redo.py @@ -9,7 +9,7 @@ from my_mocks import tabs_manager class UndoableCommand(CommandHistory): def __init__(self, old_value=0, new_value=0): - super().__init__("command", f"The new value is {new_value}") + super().__init__("Set new value", lambda value: f"Setting new value to {value}", None) self.old_value = old_value self.new_value = new_value @@ -31,8 +31,8 @@ def undo_redo(session, tabs_manager): def test_i_can_render(undo_redo): actual = undo_redo.__ft__() expected = Div( - Div(div_icon("undo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to undo"), - Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to redo"), + Div(div_icon("undo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to undo."), + Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to redo."), id=undo_redo.get_id(), ) @@ -40,13 +40,13 @@ def test_i_can_render(undo_redo): def test_i_can_render_when_undoing_and_redoing(undo_redo): - undo_redo.push(UndoableCommand()) - undo_redo.push(UndoableCommand()) + undo_redo.push(UndoableCommand(0, 1)) + undo_redo.push(UndoableCommand(1, 2)) actual = undo_redo.__ft__() expected = Div( - Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo "), - Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip=""), + Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set new value'."), + Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to redo."), id=undo_redo.get_id(), ) assert matches(actual, expected) @@ -54,8 +54,8 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo): undo_redo.undo() # The command is now undone. We can redo it and undo the first command. actual = undo_redo.__ft__() expected = Div( - div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), - div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), + Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set new value'."), + Div(div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Redo 'Set new value'."), id=undo_redo.get_id(), ) assert matches(actual, expected) @@ -63,8 +63,8 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo): undo_redo.undo() # Undo again, I cannot undo anymore. actual = undo_redo.__ft__() expected = Div( - div_icon("undo", cls=Contains("mmt-btn-disabled")), - div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), + Div(div_icon("undo", cls=Contains("mmt-btn-disabled"))), + Div(div_icon("redo", cls=DoesNotContain("mmt-btn-disabled"))), id=undo_redo.get_id(), ) assert matches(actual, expected) @@ -72,8 +72,8 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo): undo_redo.redo() # Redo once. actual = undo_redo.__ft__() expected = Div( - div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), - div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), + Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled"))), + Div(div_icon("redo", cls=DoesNotContain("mmt-btn-disabled"))), id=undo_redo.get_id(), ) assert matches(actual, expected) @@ -81,8 +81,8 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo): undo_redo.redo() # Redo a second time. actual = undo_redo.__ft__() expected = Div( - div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), - div_icon("redo", cls=Contains("mmt-btn-disabled")), + Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled"))), + Div(div_icon("redo", cls=Contains("mmt-btn-disabled"))), id=undo_redo.get_id(), ) assert matches(actual, expected) @@ -99,3 +99,14 @@ def test_i_can_undo_and_redo(undo_redo): self, res = undo_redo.redo() expected = Div(2, hx_swap_oob="true") assert matches(res, expected) + +def test_history_is_rewritten_when_pushing_a_command(undo_redo): + undo_redo.push(UndoableCommand(0, 1)) + undo_redo.push(UndoableCommand(1, 2)) + undo_redo.push(UndoableCommand(2, 3)) + + undo_redo.undo() + undo_redo.undo() + undo_redo.push(UndoableCommand(1, 5)) + + assert len(undo_redo.history) == 2 \ No newline at end of file