Add save confirmation modal and integrate with entry editing
- Implemented SaveConfirmationModal to prompt users for saving changes when exiting edit mode. - Integrated modal into HostsManagerApp to handle unsaved changes. - Added methods to validate and save entry changes, restoring original values if discarded. - Created unit tests for SaveConfirmationModal and its integration with the main application. - Refactored entry editing logic to track changes and confirm before exiting edit mode.
This commit is contained in:
parent
5a117fb624
commit
f7671db43e
5 changed files with 761 additions and 221 deletions
|
@ -2,10 +2,19 @@
|
||||||
|
|
||||||
## Current Work Focus
|
## Current Work Focus
|
||||||
|
|
||||||
**Phase 3 Complete - Edit Mode Foundation**: The hosts TUI application now has a complete edit mode foundation with permission management, entry manipulation, and safe file operations. All keyboard shortcuts are implemented and tested. The application is ready for Phase 4 advanced edit features.
|
**Phase 3 Complete with Save Confirmation Enhancement**: The hosts TUI application now has a complete edit mode foundation with permission management, entry manipulation, safe file operations, and professional save confirmation functionality. All keyboard shortcuts are implemented and tested. The application is ready for Phase 4 advanced edit features.
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
|
### Phase 3 Save Confirmation Enhancement ✅ COMPLETE
|
||||||
|
- ✅ **Save confirmation modal**: Professional modal dialog asking to save, discard, or cancel when exiting edit entry mode
|
||||||
|
- ✅ **Change detection system**: Intelligent tracking of original entry values vs. current form values
|
||||||
|
- ✅ **No auto-save behavior**: Changes are only saved when explicitly confirmed by the user
|
||||||
|
- ✅ **Graceful exit handling**: ESC key in edit entry mode now triggers save confirmation instead of auto-exiting
|
||||||
|
- ✅ **Validation integration**: Full validation before saving with clear error messages for invalid data
|
||||||
|
- ✅ **Comprehensive testing**: 13 new tests for save confirmation functionality (161 total tests)
|
||||||
|
- ✅ **Modal keyboard shortcuts**: Save (S), Discard (D), Cancel (ESC) with intuitive button labels
|
||||||
|
|
||||||
### Phase 2 Implementation Complete
|
### Phase 2 Implementation Complete
|
||||||
- ✅ **Advanced configuration system**: Complete Config class with JSON persistence to ~/.config/hosts-manager/
|
- ✅ **Advanced configuration system**: Complete Config class with JSON persistence to ~/.config/hosts-manager/
|
||||||
- ✅ **Professional configuration modal**: Modal dialog with keyboard bindings for settings management
|
- ✅ **Professional configuration modal**: Modal dialog with keyboard bindings for settings management
|
||||||
|
|
|
@ -64,7 +64,9 @@
|
||||||
- ✅ **Live testing**: Manual testing confirms all functionality works correctly
|
- ✅ **Live testing**: Manual testing confirms all functionality works correctly
|
||||||
- ✅ **Human-readable formatting**: Tab-based column alignment with proper spacing
|
- ✅ **Human-readable formatting**: Tab-based column alignment with proper spacing
|
||||||
- ✅ **Management header**: Automatic addition of management header to hosts files
|
- ✅ **Management header**: Automatic addition of management header to hosts files
|
||||||
- ✅ **Auto-save functionality**: Immediate saving of changes when entries are edited
|
- ✅ **Save confirmation modal**: Professional modal dialog for save/discard/cancel when exiting edit entry mode
|
||||||
|
- ✅ **Change detection**: Intelligent tracking of original vs. current entry values
|
||||||
|
- ✅ **No auto-save**: Changes saved only when explicitly confirmed by user
|
||||||
|
|
||||||
### Phase 4: Advanced Edit Features
|
### Phase 4: Advanced Edit Features
|
||||||
- ❌ **Add new entries**: Create new host entries
|
- ❌ **Add new entries**: Create new host entries
|
||||||
|
@ -89,8 +91,8 @@
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
### Development Stage
|
### Development Stage
|
||||||
**Stage**: Phase 3 Complete - Moving to Phase 4
|
**Stage**: Phase 3 Complete with Save Confirmation Enhancement - Ready for Phase 4
|
||||||
**Progress**: 75% (Complete edit mode foundation with permission management)
|
**Progress**: 78% (Complete edit mode foundation with professional save confirmation)
|
||||||
**Next Milestone**: Advanced edit features (add/delete entries, bulk operations)
|
**Next Milestone**: Advanced edit features (add/delete entries, bulk operations)
|
||||||
|
|
||||||
### Phase 2 Final Achievements
|
### Phase 2 Final Achievements
|
||||||
|
@ -112,7 +114,10 @@
|
||||||
6. ✅ **Manager module**: Complete HostsManager class for all edit operations
|
6. ✅ **Manager module**: Complete HostsManager class for all edit operations
|
||||||
7. ✅ **Safe file operations**: Atomic file writing with rollback capability
|
7. ✅ **Safe file operations**: Atomic file writing with rollback capability
|
||||||
8. ✅ **Enhanced error messages**: Professional read-only mode error messages with clear instructions
|
8. ✅ **Enhanced error messages**: Professional read-only mode error messages with clear instructions
|
||||||
9. ✅ **Comprehensive testing**: 38 new tests for manager module (135 total tests)
|
9. ✅ **Comprehensive testing**: 38 new tests for manager module (148 total tests)
|
||||||
|
10. ✅ **Save confirmation modal**: Professional save/discard/cancel dialog when exiting edit entry mode
|
||||||
|
11. ✅ **Change detection system**: Intelligent tracking of original vs. current entry values
|
||||||
|
12. ✅ **No auto-save behavior**: User-controlled saving with explicit confirmation
|
||||||
|
|
||||||
### Recent Major Accomplishments
|
### Recent Major Accomplishments
|
||||||
- ✅ **Complete Phase 3 implementation**: Full edit mode foundation with permission management
|
- ✅ **Complete Phase 3 implementation**: Full edit mode foundation with permission management
|
||||||
|
@ -121,7 +126,8 @@
|
||||||
- ✅ **Permission system**: Robust sudo request, validation, and release functionality
|
- ✅ **Permission system**: Robust sudo request, validation, and release functionality
|
||||||
- ✅ **File backup system**: Automatic backup creation with timestamp naming
|
- ✅ **File backup system**: Automatic backup creation with timestamp naming
|
||||||
- ✅ **Entry manipulation**: Toggle active/inactive and reorder entries safely
|
- ✅ **Entry manipulation**: Toggle active/inactive and reorder entries safely
|
||||||
- ✅ **Comprehensive testing**: 135 total tests with 100% pass rate including edit operations
|
- ✅ **Save confirmation enhancement**: Professional modal dialog system for editing workflow
|
||||||
|
- ✅ **Comprehensive testing**: 161 total tests with 100% pass rate including save confirmation
|
||||||
- ✅ **Error handling**: Graceful handling of permission errors and file operations
|
- ✅ **Error handling**: Graceful handling of permission errors and file operations
|
||||||
- ✅ **Keyboard shortcuts**: Complete set of edit mode bindings (e, space, Ctrl+Up/Down, Ctrl+S)
|
- ✅ **Keyboard shortcuts**: Complete set of edit mode bindings (e, space, Ctrl+Up/Down, Ctrl+S)
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ from .core.models import HostsFile
|
||||||
from .core.config import Config
|
from .core.config import Config
|
||||||
from .core.manager import HostsManager
|
from .core.manager import HostsManager
|
||||||
from .tui.config_modal import ConfigModal
|
from .tui.config_modal import ConfigModal
|
||||||
|
from .tui.save_confirmation_modal import SaveConfirmationModal
|
||||||
|
|
||||||
|
|
||||||
class HostsManagerApp(App):
|
class HostsManagerApp(App):
|
||||||
|
@ -151,6 +152,9 @@ class HostsManagerApp(App):
|
||||||
self.title = "Hosts Manager"
|
self.title = "Hosts Manager"
|
||||||
self.sub_title = "Read-only mode"
|
self.sub_title = "Read-only mode"
|
||||||
|
|
||||||
|
# Track original entry values for change detection
|
||||||
|
self.original_entry_values = None
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the application layout."""
|
"""Create the application layout."""
|
||||||
yield Header()
|
yield Header()
|
||||||
|
@ -173,7 +177,9 @@ class HostsManagerApp(App):
|
||||||
yield Label("Hostname:")
|
yield Label("Hostname:")
|
||||||
yield Input(id="hostname-input", placeholder="Enter hostname")
|
yield Input(id="hostname-input", placeholder="Enter hostname")
|
||||||
yield Label("Comment:")
|
yield Label("Comment:")
|
||||||
yield Input(id="comment-input", placeholder="Enter comment (optional)")
|
yield Input(
|
||||||
|
id="comment-input", placeholder="Enter comment (optional)"
|
||||||
|
)
|
||||||
yield Label("Active:")
|
yield Label("Active:")
|
||||||
yield Checkbox(id="active-checkbox", value=True)
|
yield Checkbox(id="active-checkbox", value=True)
|
||||||
yield right_pane
|
yield right_pane
|
||||||
|
@ -191,7 +197,9 @@ class HostsManagerApp(App):
|
||||||
"""Load the hosts file and populate the interface."""
|
"""Load the hosts file and populate the interface."""
|
||||||
# Remember current selection for restoration
|
# Remember current selection for restoration
|
||||||
current_entry = None
|
current_entry = None
|
||||||
if self.hosts_file.entries and self.selected_entry_index < len(self.hosts_file.entries):
|
if self.hosts_file.entries and self.selected_entry_index < len(
|
||||||
|
self.hosts_file.entries
|
||||||
|
):
|
||||||
current_entry = self.hosts_file.entries[self.selected_entry_index]
|
current_entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -221,7 +229,9 @@ class HostsManagerApp(App):
|
||||||
for entry in self.hosts_file.entries:
|
for entry in self.hosts_file.entries:
|
||||||
canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
|
canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
|
||||||
# Skip default entries if configured to hide them
|
# Skip default entries if configured to hide them
|
||||||
if not show_defaults and self.config.is_default_entry(entry.ip_address, canonical_hostname):
|
if not show_defaults and self.config.is_default_entry(
|
||||||
|
entry.ip_address, canonical_hostname
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
visible_entries.append(entry)
|
visible_entries.append(entry)
|
||||||
|
|
||||||
|
@ -234,7 +244,9 @@ class HostsManagerApp(App):
|
||||||
for i, entry in enumerate(self.hosts_file.entries):
|
for i, entry in enumerate(self.hosts_file.entries):
|
||||||
canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
|
canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
|
||||||
# Skip default entries if configured to hide them
|
# Skip default entries if configured to hide them
|
||||||
if not show_defaults and self.config.is_default_entry(entry.ip_address, canonical_hostname):
|
if not show_defaults and self.config.is_default_entry(
|
||||||
|
entry.ip_address, canonical_hostname
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
return i
|
return i
|
||||||
|
|
||||||
|
@ -340,9 +352,11 @@ class HostsManagerApp(App):
|
||||||
else:
|
else:
|
||||||
# Try to find the same entry in the reloaded file
|
# Try to find the same entry in the reloaded file
|
||||||
for i, entry in enumerate(self.hosts_file.entries):
|
for i, entry in enumerate(self.hosts_file.entries):
|
||||||
if (entry.ip_address == previous_entry.ip_address and
|
if (
|
||||||
entry.hostnames == previous_entry.hostnames and
|
entry.ip_address == previous_entry.ip_address
|
||||||
entry.comment == previous_entry.comment):
|
and entry.hostnames == previous_entry.hostnames
|
||||||
|
and entry.comment == previous_entry.comment
|
||||||
|
):
|
||||||
self.selected_entry_index = i
|
self.selected_entry_index = i
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
@ -390,8 +404,12 @@ class HostsManagerApp(App):
|
||||||
show_defaults = self.config.should_show_default_entries()
|
show_defaults = self.config.should_show_default_entries()
|
||||||
if not show_defaults:
|
if not show_defaults:
|
||||||
# Check if the currently selected entry is a default entry (hidden)
|
# Check if the currently selected entry is a default entry (hidden)
|
||||||
if (self.selected_entry_index < len(self.hosts_file.entries) and
|
if (
|
||||||
self.hosts_file.entries[self.selected_entry_index].is_default_entry()):
|
self.selected_entry_index < len(self.hosts_file.entries)
|
||||||
|
and self.hosts_file.entries[
|
||||||
|
self.selected_entry_index
|
||||||
|
].is_default_entry()
|
||||||
|
):
|
||||||
# The selected entry is hidden, so we should show the first visible entry instead
|
# The selected entry is hidden, so we should show the first visible entry instead
|
||||||
if visible_entries:
|
if visible_entries:
|
||||||
# Find the first visible entry in the hosts file
|
# Find the first visible entry in the hosts file
|
||||||
|
@ -415,7 +433,9 @@ class HostsManagerApp(App):
|
||||||
if entry.is_default_entry():
|
if entry.is_default_entry():
|
||||||
details_lines.append("")
|
details_lines.append("")
|
||||||
details_lines.append("⚠️ SYSTEM DEFAULT ENTRY")
|
details_lines.append("⚠️ SYSTEM DEFAULT ENTRY")
|
||||||
details_lines.append("This is a default system entry and cannot be modified.")
|
details_lines.append(
|
||||||
|
"This is a default system entry and cannot be modified."
|
||||||
|
)
|
||||||
|
|
||||||
if entry.comment:
|
if entry.comment:
|
||||||
details_lines.append(f"Comment: {entry.comment}")
|
details_lines.append(f"Comment: {entry.comment}")
|
||||||
|
@ -434,7 +454,9 @@ class HostsManagerApp(App):
|
||||||
details_widget.add_class("hidden")
|
details_widget.add_class("hidden")
|
||||||
edit_form.remove_class("hidden")
|
edit_form.remove_class("hidden")
|
||||||
|
|
||||||
if not self.hosts_file.entries or self.selected_entry_index >= len(self.hosts_file.entries):
|
if not self.hosts_file.entries or self.selected_entry_index >= len(
|
||||||
|
self.hosts_file.entries
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
entry = self.hosts_file.entries[self.selected_entry_index]
|
entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
@ -446,7 +468,7 @@ class HostsManagerApp(App):
|
||||||
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
ip_input.value = entry.ip_address
|
ip_input.value = entry.ip_address
|
||||||
hostname_input.value = ', '.join(entry.hostnames)
|
hostname_input.value = ", ".join(entry.hostnames)
|
||||||
comment_input.value = entry.comment or ""
|
comment_input.value = entry.comment or ""
|
||||||
active_checkbox.value = entry.is_active
|
active_checkbox.value = entry.is_active
|
||||||
|
|
||||||
|
@ -483,7 +505,7 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# Add file info
|
# Add file info
|
||||||
file_info = self.parser.get_file_info()
|
file_info = self.parser.get_file_info()
|
||||||
if file_info['exists']:
|
if file_info["exists"]:
|
||||||
status_text += f" | {file_info['path']}"
|
status_text += f" | {file_info['path']}"
|
||||||
|
|
||||||
status_widget.update(status_text)
|
status_widget.update(status_text)
|
||||||
|
@ -492,14 +514,18 @@ class HostsManagerApp(App):
|
||||||
"""Handle row highlighting (cursor movement) in the DataTable."""
|
"""Handle row highlighting (cursor movement) in the DataTable."""
|
||||||
if event.data_table.id == "entries-table":
|
if event.data_table.id == "entries-table":
|
||||||
# Convert display index to actual index
|
# Convert display index to actual index
|
||||||
self.selected_entry_index = self.display_index_to_actual_index(event.cursor_row)
|
self.selected_entry_index = self.display_index_to_actual_index(
|
||||||
|
event.cursor_row
|
||||||
|
)
|
||||||
self.update_entry_details()
|
self.update_entry_details()
|
||||||
|
|
||||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
"""Handle row selection in the DataTable."""
|
"""Handle row selection in the DataTable."""
|
||||||
if event.data_table.id == "entries-table":
|
if event.data_table.id == "entries-table":
|
||||||
# Convert display index to actual index
|
# Convert display index to actual index
|
||||||
self.selected_entry_index = self.display_index_to_actual_index(event.cursor_row)
|
self.selected_entry_index = self.display_index_to_actual_index(
|
||||||
|
event.cursor_row
|
||||||
|
)
|
||||||
self.update_entry_details()
|
self.update_entry_details()
|
||||||
|
|
||||||
def action_reload(self) -> None:
|
def action_reload(self) -> None:
|
||||||
|
@ -513,10 +539,13 @@ class HostsManagerApp(App):
|
||||||
def action_help(self) -> None:
|
def action_help(self) -> None:
|
||||||
"""Show help information."""
|
"""Show help information."""
|
||||||
# For now, just update the status with help info
|
# For now, just update the status with help info
|
||||||
self.update_status("Help: ↑/↓ Navigate, r Reload, q Quit, h Help, c Config, e Edit")
|
self.update_status(
|
||||||
|
"Help: ↑/↓ Navigate, r Reload, q Quit, h Help, c Config, e Edit"
|
||||||
|
)
|
||||||
|
|
||||||
def action_config(self) -> None:
|
def action_config(self) -> None:
|
||||||
"""Show configuration modal."""
|
"""Show configuration modal."""
|
||||||
|
|
||||||
def handle_config_result(config_changed: bool) -> None:
|
def handle_config_result(config_changed: bool) -> None:
|
||||||
if config_changed:
|
if config_changed:
|
||||||
# Reload the table to apply new filtering
|
# Reload the table to apply new filtering
|
||||||
|
@ -590,7 +619,9 @@ class HostsManagerApp(App):
|
||||||
def action_edit_entry(self) -> None:
|
def action_edit_entry(self) -> None:
|
||||||
"""Enter edit mode for the selected entry."""
|
"""Enter edit mode for the selected entry."""
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status("❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.")
|
self.update_status(
|
||||||
|
"❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.hosts_file.entries:
|
if not self.hosts_file.entries:
|
||||||
|
@ -606,6 +637,14 @@ class HostsManagerApp(App):
|
||||||
self.update_status("❌ Cannot edit system default entry")
|
self.update_status("❌ Cannot edit system default entry")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Store original values for change detection
|
||||||
|
self.original_entry_values = {
|
||||||
|
"ip_address": entry.ip_address,
|
||||||
|
"hostnames": entry.hostnames.copy(),
|
||||||
|
"comment": entry.comment,
|
||||||
|
"is_active": entry.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
self.entry_edit_mode = True
|
self.entry_edit_mode = True
|
||||||
self.update_entry_details()
|
self.update_entry_details()
|
||||||
|
|
||||||
|
@ -615,10 +654,60 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
self.update_status("Editing entry - Use Tab/Shift+Tab to navigate, ESC to exit")
|
self.update_status("Editing entry - Use Tab/Shift+Tab to navigate, ESC to exit")
|
||||||
|
|
||||||
|
def has_entry_changes(self) -> bool:
|
||||||
|
"""Check if the current entry has been modified from its original values."""
|
||||||
|
if not self.original_entry_values or not self.entry_edit_mode:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get current values from form fields
|
||||||
|
ip_input = self.query_one("#ip-input", Input)
|
||||||
|
hostname_input = self.query_one("#hostname-input", Input)
|
||||||
|
comment_input = self.query_one("#comment-input", Input)
|
||||||
|
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
|
current_hostnames = [
|
||||||
|
h.strip() for h in hostname_input.value.split(",") if h.strip()
|
||||||
|
]
|
||||||
|
current_comment = comment_input.value.strip() or None
|
||||||
|
|
||||||
|
# Compare with original values
|
||||||
|
return (
|
||||||
|
ip_input.value.strip() != self.original_entry_values["ip_address"]
|
||||||
|
or current_hostnames != self.original_entry_values["hostnames"]
|
||||||
|
or current_comment != self.original_entry_values["comment"]
|
||||||
|
or active_checkbox.value != self.original_entry_values["is_active"]
|
||||||
|
)
|
||||||
|
|
||||||
def action_exit_edit_entry(self) -> None:
|
def action_exit_edit_entry(self) -> None:
|
||||||
"""Exit entry edit mode and return focus to the entries table."""
|
"""Exit entry edit mode and return focus to the entries table."""
|
||||||
if self.entry_edit_mode:
|
if not self.entry_edit_mode:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there are unsaved changes
|
||||||
|
if self.has_entry_changes():
|
||||||
|
# Show save confirmation modal
|
||||||
|
def handle_save_confirmation(result):
|
||||||
|
if result == "save":
|
||||||
|
# Validate and save changes
|
||||||
|
if self.validate_and_save_entry_changes():
|
||||||
|
self.exit_edit_entry_mode()
|
||||||
|
elif result == "discard":
|
||||||
|
# Restore original values and exit
|
||||||
|
self.restore_original_entry_values()
|
||||||
|
self.exit_edit_entry_mode()
|
||||||
|
elif result == "cancel":
|
||||||
|
# Do nothing, stay in edit mode
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.push_screen(SaveConfirmationModal(), handle_save_confirmation)
|
||||||
|
else:
|
||||||
|
# No changes, exit directly
|
||||||
|
self.exit_edit_entry_mode()
|
||||||
|
|
||||||
|
def exit_edit_entry_mode(self) -> None:
|
||||||
|
"""Helper method to exit entry edit mode and clean up."""
|
||||||
self.entry_edit_mode = False
|
self.entry_edit_mode = False
|
||||||
|
self.original_entry_values = None
|
||||||
self.update_entry_details()
|
self.update_entry_details()
|
||||||
|
|
||||||
# Return focus to the entries table
|
# Return focus to the entries table
|
||||||
|
@ -627,6 +716,87 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
self.update_status("Exited entry edit mode")
|
self.update_status("Exited entry edit mode")
|
||||||
|
|
||||||
|
def restore_original_entry_values(self) -> None:
|
||||||
|
"""Restore the original values to the form fields."""
|
||||||
|
if not self.original_entry_values:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update form fields with original values
|
||||||
|
ip_input = self.query_one("#ip-input", Input)
|
||||||
|
hostname_input = self.query_one("#hostname-input", Input)
|
||||||
|
comment_input = self.query_one("#comment-input", Input)
|
||||||
|
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
|
ip_input.value = self.original_entry_values["ip_address"]
|
||||||
|
hostname_input.value = ", ".join(self.original_entry_values["hostnames"])
|
||||||
|
comment_input.value = self.original_entry_values["comment"] or ""
|
||||||
|
active_checkbox.value = self.original_entry_values["is_active"]
|
||||||
|
|
||||||
|
def validate_and_save_entry_changes(self) -> bool:
|
||||||
|
"""Validate current entry values and save if valid."""
|
||||||
|
if not self.hosts_file.entries or self.selected_entry_index >= len(
|
||||||
|
self.hosts_file.entries
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
|
||||||
|
# Get values from form fields
|
||||||
|
ip_input = self.query_one("#ip-input", Input)
|
||||||
|
hostname_input = self.query_one("#hostname-input", Input)
|
||||||
|
comment_input = self.query_one("#comment-input", Input)
|
||||||
|
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
|
# Validate IP address
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(ip_input.value.strip())
|
||||||
|
except ValueError:
|
||||||
|
self.update_status("❌ Invalid IP address - changes not saved")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Validate hostname(s)
|
||||||
|
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
|
||||||
|
if not hostnames:
|
||||||
|
self.update_status(
|
||||||
|
"❌ At least one hostname is required - changes not saved"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
hostname_pattern = re.compile(
|
||||||
|
r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
for hostname in hostnames:
|
||||||
|
if not hostname_pattern.match(hostname):
|
||||||
|
self.update_status(
|
||||||
|
f"❌ Invalid hostname: {hostname} - changes not saved"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update the entry
|
||||||
|
entry.ip_address = ip_input.value.strip()
|
||||||
|
entry.hostnames = hostnames
|
||||||
|
entry.comment = comment_input.value.strip() or None
|
||||||
|
entry.is_active = active_checkbox.value
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
success, message = self.manager.save_hosts_file(self.hosts_file)
|
||||||
|
if success:
|
||||||
|
# Update the table display
|
||||||
|
self.populate_entries_table()
|
||||||
|
# Restore cursor position
|
||||||
|
table = self.query_one("#entries-table", DataTable)
|
||||||
|
display_index = self.actual_index_to_display_index(
|
||||||
|
self.selected_entry_index
|
||||||
|
)
|
||||||
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
|
table.move_cursor(row=display_index)
|
||||||
|
self.update_status("Entry saved successfully")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.update_status(f"❌ Error saving entry: {message}")
|
||||||
|
return False
|
||||||
|
|
||||||
def action_next_field(self) -> None:
|
def action_next_field(self) -> None:
|
||||||
"""Move to the next field in edit mode."""
|
"""Move to the next field in edit mode."""
|
||||||
if not self.entry_edit_mode:
|
if not self.entry_edit_mode:
|
||||||
|
@ -637,7 +807,7 @@ class HostsManagerApp(App):
|
||||||
self.query_one("#ip-input", Input),
|
self.query_one("#ip-input", Input),
|
||||||
self.query_one("#hostname-input", Input),
|
self.query_one("#hostname-input", Input),
|
||||||
self.query_one("#comment-input", Input),
|
self.query_one("#comment-input", Input),
|
||||||
self.query_one("#active-checkbox", Checkbox)
|
self.query_one("#active-checkbox", Checkbox),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Find currently focused field and move to next
|
# Find currently focused field and move to next
|
||||||
|
@ -657,7 +827,7 @@ class HostsManagerApp(App):
|
||||||
self.query_one("#ip-input", Input),
|
self.query_one("#ip-input", Input),
|
||||||
self.query_one("#hostname-input", Input),
|
self.query_one("#hostname-input", Input),
|
||||||
self.query_one("#comment-input", Input),
|
self.query_one("#comment-input", Input),
|
||||||
self.query_one("#active-checkbox", Checkbox)
|
self.query_one("#active-checkbox", Checkbox),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Find currently focused field and move to previous
|
# Find currently focused field and move to previous
|
||||||
|
@ -679,80 +849,23 @@ class HostsManagerApp(App):
|
||||||
self.action_prev_field()
|
self.action_prev_field()
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed) -> None:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
"""Handle input field changes and auto-save."""
|
"""Handle input field changes (no auto-save - changes saved on exit)."""
|
||||||
if not self.entry_edit_mode or not self.edit_mode:
|
# Input changes are tracked but not automatically saved
|
||||||
return
|
# Changes will be validated and saved when exiting edit mode
|
||||||
|
pass
|
||||||
if event.input.id in ["ip-input", "hostname-input", "comment-input"]:
|
|
||||||
self.save_entry_changes()
|
|
||||||
|
|
||||||
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
||||||
"""Handle checkbox changes and auto-save."""
|
"""Handle checkbox changes (no auto-save - changes saved on exit)."""
|
||||||
if not self.entry_edit_mode or not self.edit_mode:
|
# Checkbox changes are tracked but not automatically saved
|
||||||
return
|
# Changes will be validated and saved when exiting edit mode
|
||||||
|
pass
|
||||||
if event.checkbox.id == "active-checkbox":
|
|
||||||
self.save_entry_changes()
|
|
||||||
|
|
||||||
def save_entry_changes(self) -> None:
|
|
||||||
"""Save the current entry changes."""
|
|
||||||
if not self.hosts_file.entries or self.selected_entry_index >= len(self.hosts_file.entries):
|
|
||||||
return
|
|
||||||
|
|
||||||
entry = self.hosts_file.entries[self.selected_entry_index]
|
|
||||||
|
|
||||||
# Get values from form fields
|
|
||||||
ip_input = self.query_one("#ip-input", Input)
|
|
||||||
hostname_input = self.query_one("#hostname-input", Input)
|
|
||||||
comment_input = self.query_one("#comment-input", Input)
|
|
||||||
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
|
||||||
|
|
||||||
# Validate IP address
|
|
||||||
try:
|
|
||||||
ipaddress.ip_address(ip_input.value.strip())
|
|
||||||
except ValueError:
|
|
||||||
self.update_status("❌ Invalid IP address")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate hostname(s)
|
|
||||||
hostnames = [h.strip() for h in hostname_input.value.split(',') if h.strip()]
|
|
||||||
if not hostnames:
|
|
||||||
self.update_status("❌ At least one hostname is required")
|
|
||||||
return
|
|
||||||
|
|
||||||
hostname_pattern = re.compile(
|
|
||||||
r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$'
|
|
||||||
)
|
|
||||||
|
|
||||||
for hostname in hostnames:
|
|
||||||
if not hostname_pattern.match(hostname):
|
|
||||||
self.update_status(f"❌ Invalid hostname: {hostname}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update the entry
|
|
||||||
entry.ip_address = ip_input.value.strip()
|
|
||||||
entry.hostnames = hostnames
|
|
||||||
entry.comment = comment_input.value.strip() or None
|
|
||||||
entry.is_active = active_checkbox.value
|
|
||||||
|
|
||||||
# Save to file
|
|
||||||
success, message = self.manager.save_hosts_file(self.hosts_file)
|
|
||||||
if success:
|
|
||||||
# Update the table display
|
|
||||||
self.populate_entries_table()
|
|
||||||
# Restore cursor position
|
|
||||||
table = self.query_one("#entries-table", DataTable)
|
|
||||||
display_index = self.actual_index_to_display_index(self.selected_entry_index)
|
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
|
||||||
table.move_cursor(row=display_index)
|
|
||||||
self.update_status("Entry saved successfully")
|
|
||||||
else:
|
|
||||||
self.update_status(f"❌ Error saving entry: {message}")
|
|
||||||
|
|
||||||
def action_toggle_entry(self) -> None:
|
def action_toggle_entry(self) -> None:
|
||||||
"""Toggle the active state of the selected entry."""
|
"""Toggle the active state of the selected entry."""
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status("❌ Cannot toggle entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.")
|
self.update_status(
|
||||||
|
"❌ Cannot toggle entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.hosts_file.entries:
|
if not self.hosts_file.entries:
|
||||||
|
@ -762,7 +875,9 @@ class HostsManagerApp(App):
|
||||||
# Remember current entry for cursor position restoration
|
# Remember current entry for cursor position restoration
|
||||||
current_entry = self.hosts_file.entries[self.selected_entry_index]
|
current_entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
|
||||||
success, message = self.manager.toggle_entry(self.hosts_file, self.selected_entry_index)
|
success, message = self.manager.toggle_entry(
|
||||||
|
self.hosts_file, self.selected_entry_index
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
||||||
|
@ -780,14 +895,18 @@ class HostsManagerApp(App):
|
||||||
def action_move_entry_up(self) -> None:
|
def action_move_entry_up(self) -> None:
|
||||||
"""Move the selected entry up in the list."""
|
"""Move the selected entry up in the list."""
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status("❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.")
|
self.update_status(
|
||||||
|
"❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.hosts_file.entries:
|
if not self.hosts_file.entries:
|
||||||
self.update_status("No entries to move")
|
self.update_status("No entries to move")
|
||||||
return
|
return
|
||||||
|
|
||||||
success, message = self.manager.move_entry_up(self.hosts_file, self.selected_entry_index)
|
success, message = self.manager.move_entry_up(
|
||||||
|
self.hosts_file, self.selected_entry_index
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
||||||
|
@ -798,7 +917,9 @@ class HostsManagerApp(App):
|
||||||
self.populate_entries_table()
|
self.populate_entries_table()
|
||||||
# Update the DataTable cursor position to follow the moved entry
|
# Update the DataTable cursor position to follow the moved entry
|
||||||
table = self.query_one("#entries-table", DataTable)
|
table = self.query_one("#entries-table", DataTable)
|
||||||
display_index = self.actual_index_to_display_index(self.selected_entry_index)
|
display_index = self.actual_index_to_display_index(
|
||||||
|
self.selected_entry_index
|
||||||
|
)
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
self.update_entry_details()
|
self.update_entry_details()
|
||||||
|
@ -811,14 +932,18 @@ class HostsManagerApp(App):
|
||||||
def action_move_entry_down(self) -> None:
|
def action_move_entry_down(self) -> None:
|
||||||
"""Move the selected entry down in the list."""
|
"""Move the selected entry down in the list."""
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status("❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.")
|
self.update_status(
|
||||||
|
"❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.hosts_file.entries:
|
if not self.hosts_file.entries:
|
||||||
self.update_status("No entries to move")
|
self.update_status("No entries to move")
|
||||||
return
|
return
|
||||||
|
|
||||||
success, message = self.manager.move_entry_down(self.hosts_file, self.selected_entry_index)
|
success, message = self.manager.move_entry_down(
|
||||||
|
self.hosts_file, self.selected_entry_index
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
||||||
|
@ -829,7 +954,9 @@ class HostsManagerApp(App):
|
||||||
self.populate_entries_table()
|
self.populate_entries_table()
|
||||||
# Update the DataTable cursor position to follow the moved entry
|
# Update the DataTable cursor position to follow the moved entry
|
||||||
table = self.query_one("#entries-table", DataTable)
|
table = self.query_one("#entries-table", DataTable)
|
||||||
display_index = self.actual_index_to_display_index(self.selected_entry_index)
|
display_index = self.actual_index_to_display_index(
|
||||||
|
self.selected_entry_index
|
||||||
|
)
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
self.update_entry_details()
|
self.update_entry_details()
|
||||||
|
@ -842,7 +969,9 @@ class HostsManagerApp(App):
|
||||||
def action_save_file(self) -> None:
|
def action_save_file(self) -> None:
|
||||||
"""Save the hosts file to disk."""
|
"""Save the hosts file to disk."""
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status("❌ Cannot save: Application is in read-only mode. No changes to save.")
|
self.update_status(
|
||||||
|
"❌ Cannot save: Application is in read-only mode. No changes to save."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
success, message = self.manager.save_hosts_file(self.hosts_file)
|
success, message = self.manager.save_hosts_file(self.hosts_file)
|
||||||
|
|
112
src/hosts/tui/save_confirmation_modal.py
Normal file
112
src/hosts/tui/save_confirmation_modal.py
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
"""
|
||||||
|
Save confirmation modal for the hosts TUI application.
|
||||||
|
|
||||||
|
This module provides a modal dialog to confirm saving changes when exiting edit entry mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
from textual.widgets import Static, Button, Label
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.binding import Binding
|
||||||
|
|
||||||
|
|
||||||
|
class SaveConfirmationModal(ModalScreen):
|
||||||
|
"""
|
||||||
|
Modal screen for save confirmation when exiting edit entry mode.
|
||||||
|
|
||||||
|
Provides a confirmation dialog asking whether to save or discard changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
SaveConfirmationModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-confirmation-container {
|
||||||
|
width: 60;
|
||||||
|
height: 12;
|
||||||
|
background: $surface;
|
||||||
|
border: thick $primary;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-confirmation-title {
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
color: $primary;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-confirmation-message {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-confirmation-button {
|
||||||
|
margin: 0 1;
|
||||||
|
min-width: 12;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "cancel", "Cancel"),
|
||||||
|
Binding("enter", "save", "Save"),
|
||||||
|
Binding("s", "save", "Save"),
|
||||||
|
Binding("d", "discard", "Discard"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Create the save confirmation modal layout."""
|
||||||
|
with Vertical(classes="save-confirmation-container"):
|
||||||
|
yield Static("Save Changes?", classes="save-confirmation-title")
|
||||||
|
yield Label(
|
||||||
|
"You have made changes to this entry.\nDo you want to save or discard them?",
|
||||||
|
classes="save-confirmation-message",
|
||||||
|
)
|
||||||
|
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield Button(
|
||||||
|
"Save (S)",
|
||||||
|
variant="primary",
|
||||||
|
id="save-button",
|
||||||
|
classes="save-confirmation-button",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Discard (D)",
|
||||||
|
variant="default",
|
||||||
|
id="discard-button",
|
||||||
|
classes="save-confirmation-button",
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Cancel (ESC)",
|
||||||
|
variant="default",
|
||||||
|
id="cancel-button",
|
||||||
|
classes="save-confirmation-button",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses."""
|
||||||
|
if event.button.id == "save-button":
|
||||||
|
self.action_save()
|
||||||
|
elif event.button.id == "discard-button":
|
||||||
|
self.action_discard()
|
||||||
|
elif event.button.id == "cancel-button":
|
||||||
|
self.action_cancel()
|
||||||
|
|
||||||
|
def action_save(self) -> None:
|
||||||
|
"""Save changes and close modal."""
|
||||||
|
self.dismiss("save")
|
||||||
|
|
||||||
|
def action_discard(self) -> None:
|
||||||
|
"""Discard changes and close modal."""
|
||||||
|
self.dismiss("discard")
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
"""Cancel operation and close modal."""
|
||||||
|
self.dismiss("cancel")
|
284
tests/test_save_confirmation_modal.py
Normal file
284
tests/test_save_confirmation_modal.py
Normal file
|
@ -0,0 +1,284 @@
|
||||||
|
"""
|
||||||
|
Tests for the save confirmation modal.
|
||||||
|
|
||||||
|
This module tests the save confirmation functionality when exiting edit entry mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from textual.widgets import Input, Checkbox
|
||||||
|
|
||||||
|
from hosts.main import HostsManagerApp
|
||||||
|
from hosts.core.models import HostsFile, HostEntry
|
||||||
|
from hosts.tui.save_confirmation_modal import SaveConfirmationModal
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveConfirmationModal:
|
||||||
|
"""Test cases for the SaveConfirmationModal class."""
|
||||||
|
|
||||||
|
def test_modal_creation(self):
|
||||||
|
"""Test that the modal can be created."""
|
||||||
|
modal = SaveConfirmationModal()
|
||||||
|
assert modal is not None
|
||||||
|
|
||||||
|
def test_modal_compose(self):
|
||||||
|
"""Test that the modal composes correctly."""
|
||||||
|
# Note: Cannot test compose() directly without app context
|
||||||
|
# This is a basic existence check for the modal
|
||||||
|
modal = SaveConfirmationModal()
|
||||||
|
assert hasattr(modal, "compose")
|
||||||
|
assert callable(modal.compose)
|
||||||
|
|
||||||
|
def test_action_save(self):
|
||||||
|
"""Test save action dismisses with 'save'."""
|
||||||
|
modal = SaveConfirmationModal()
|
||||||
|
modal.dismiss = Mock()
|
||||||
|
|
||||||
|
modal.action_save()
|
||||||
|
|
||||||
|
modal.dismiss.assert_called_once_with("save")
|
||||||
|
|
||||||
|
def test_action_discard(self):
|
||||||
|
"""Test discard action dismisses with 'discard'."""
|
||||||
|
modal = SaveConfirmationModal()
|
||||||
|
modal.dismiss = Mock()
|
||||||
|
|
||||||
|
modal.action_discard()
|
||||||
|
|
||||||
|
modal.dismiss.assert_called_once_with("discard")
|
||||||
|
|
||||||
|
def test_action_cancel(self):
|
||||||
|
"""Test cancel action dismisses with 'cancel'."""
|
||||||
|
modal = SaveConfirmationModal()
|
||||||
|
modal.dismiss = Mock()
|
||||||
|
|
||||||
|
modal.action_cancel()
|
||||||
|
|
||||||
|
modal.dismiss.assert_called_once_with("cancel")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveConfirmationIntegration:
|
||||||
|
"""Test cases for save confirmation integration with the main app."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self):
|
||||||
|
"""Create a test app instance."""
|
||||||
|
return HostsManagerApp()
|
||||||
|
|
||||||
|
def test_has_entry_changes_no_original_values(self, app):
|
||||||
|
"""Test has_entry_changes returns False when no original values stored."""
|
||||||
|
app.original_entry_values = None
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
|
||||||
|
assert not app.has_entry_changes()
|
||||||
|
|
||||||
|
def test_has_entry_changes_not_in_edit_mode(self, app):
|
||||||
|
"""Test has_entry_changes returns False when not in edit mode."""
|
||||||
|
app.original_entry_values = {
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"hostnames": ["localhost"],
|
||||||
|
"comment": None,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
app.entry_edit_mode = False
|
||||||
|
|
||||||
|
assert not app.has_entry_changes()
|
||||||
|
|
||||||
|
@patch.object(HostsManagerApp, "query_one")
|
||||||
|
def test_has_entry_changes_no_changes(self, mock_query_one, app):
|
||||||
|
"""Test has_entry_changes returns False when no changes made."""
|
||||||
|
# Setup original values
|
||||||
|
app.original_entry_values = {
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"hostnames": ["localhost"],
|
||||||
|
"comment": None,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
|
||||||
|
# Mock form fields with original values
|
||||||
|
mock_ip_input = Mock()
|
||||||
|
mock_ip_input.value = "127.0.0.1"
|
||||||
|
mock_hostname_input = Mock()
|
||||||
|
mock_hostname_input.value = "localhost"
|
||||||
|
mock_comment_input = Mock()
|
||||||
|
mock_comment_input.value = ""
|
||||||
|
mock_checkbox = Mock()
|
||||||
|
mock_checkbox.value = True
|
||||||
|
|
||||||
|
def mock_query_side_effect(selector, widget_type=None):
|
||||||
|
if selector == "#ip-input":
|
||||||
|
return mock_ip_input
|
||||||
|
elif selector == "#hostname-input":
|
||||||
|
return mock_hostname_input
|
||||||
|
elif selector == "#comment-input":
|
||||||
|
return mock_comment_input
|
||||||
|
elif selector == "#active-checkbox":
|
||||||
|
return mock_checkbox
|
||||||
|
|
||||||
|
mock_query_one.side_effect = mock_query_side_effect
|
||||||
|
|
||||||
|
assert not app.has_entry_changes()
|
||||||
|
|
||||||
|
@patch.object(HostsManagerApp, "query_one")
|
||||||
|
def test_has_entry_changes_ip_changed(self, mock_query_one, app):
|
||||||
|
"""Test has_entry_changes returns True when IP address changed."""
|
||||||
|
# Setup original values
|
||||||
|
app.original_entry_values = {
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"hostnames": ["localhost"],
|
||||||
|
"comment": None,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
|
||||||
|
# Mock form fields with changed IP
|
||||||
|
mock_ip_input = Mock()
|
||||||
|
mock_ip_input.value = "192.168.1.1" # Changed IP
|
||||||
|
mock_hostname_input = Mock()
|
||||||
|
mock_hostname_input.value = "localhost"
|
||||||
|
mock_comment_input = Mock()
|
||||||
|
mock_comment_input.value = ""
|
||||||
|
mock_checkbox = Mock()
|
||||||
|
mock_checkbox.value = True
|
||||||
|
|
||||||
|
def mock_query_side_effect(selector, widget_type=None):
|
||||||
|
if selector == "#ip-input":
|
||||||
|
return mock_ip_input
|
||||||
|
elif selector == "#hostname-input":
|
||||||
|
return mock_hostname_input
|
||||||
|
elif selector == "#comment-input":
|
||||||
|
return mock_comment_input
|
||||||
|
elif selector == "#active-checkbox":
|
||||||
|
return mock_checkbox
|
||||||
|
|
||||||
|
mock_query_one.side_effect = mock_query_side_effect
|
||||||
|
|
||||||
|
assert app.has_entry_changes()
|
||||||
|
|
||||||
|
@patch.object(HostsManagerApp, "query_one")
|
||||||
|
def test_has_entry_changes_hostname_changed(self, mock_query_one, app):
|
||||||
|
"""Test has_entry_changes returns True when hostname changed."""
|
||||||
|
# Setup original values
|
||||||
|
app.original_entry_values = {
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"hostnames": ["localhost"],
|
||||||
|
"comment": None,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
|
||||||
|
# Mock form fields with changed hostname
|
||||||
|
mock_ip_input = Mock()
|
||||||
|
mock_ip_input.value = "127.0.0.1"
|
||||||
|
mock_hostname_input = Mock()
|
||||||
|
mock_hostname_input.value = "localhost, test.local" # Added hostname
|
||||||
|
mock_comment_input = Mock()
|
||||||
|
mock_comment_input.value = ""
|
||||||
|
mock_checkbox = Mock()
|
||||||
|
mock_checkbox.value = True
|
||||||
|
|
||||||
|
def mock_query_side_effect(selector, widget_type=None):
|
||||||
|
if selector == "#ip-input":
|
||||||
|
return mock_ip_input
|
||||||
|
elif selector == "#hostname-input":
|
||||||
|
return mock_hostname_input
|
||||||
|
elif selector == "#comment-input":
|
||||||
|
return mock_comment_input
|
||||||
|
elif selector == "#active-checkbox":
|
||||||
|
return mock_checkbox
|
||||||
|
|
||||||
|
mock_query_one.side_effect = mock_query_side_effect
|
||||||
|
|
||||||
|
assert app.has_entry_changes()
|
||||||
|
|
||||||
|
@patch.object(HostsManagerApp, "query_one")
|
||||||
|
def test_has_entry_changes_comment_added(self, mock_query_one, app):
|
||||||
|
"""Test has_entry_changes returns True when comment added."""
|
||||||
|
# Setup original values
|
||||||
|
app.original_entry_values = {
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"hostnames": ["localhost"],
|
||||||
|
"comment": None,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
|
||||||
|
# Mock form fields with added comment
|
||||||
|
mock_ip_input = Mock()
|
||||||
|
mock_ip_input.value = "127.0.0.1"
|
||||||
|
mock_hostname_input = Mock()
|
||||||
|
mock_hostname_input.value = "localhost"
|
||||||
|
mock_comment_input = Mock()
|
||||||
|
mock_comment_input.value = "Test comment" # Added comment
|
||||||
|
mock_checkbox = Mock()
|
||||||
|
mock_checkbox.value = True
|
||||||
|
|
||||||
|
def mock_query_side_effect(selector, widget_type=None):
|
||||||
|
if selector == "#ip-input":
|
||||||
|
return mock_ip_input
|
||||||
|
elif selector == "#hostname-input":
|
||||||
|
return mock_hostname_input
|
||||||
|
elif selector == "#comment-input":
|
||||||
|
return mock_comment_input
|
||||||
|
elif selector == "#active-checkbox":
|
||||||
|
return mock_checkbox
|
||||||
|
|
||||||
|
mock_query_one.side_effect = mock_query_side_effect
|
||||||
|
|
||||||
|
assert app.has_entry_changes()
|
||||||
|
|
||||||
|
@patch.object(HostsManagerApp, "query_one")
|
||||||
|
def test_has_entry_changes_active_state_changed(self, mock_query_one, app):
|
||||||
|
"""Test has_entry_changes returns True when active state changed."""
|
||||||
|
# Setup original values
|
||||||
|
app.original_entry_values = {
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"hostnames": ["localhost"],
|
||||||
|
"comment": None,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
|
||||||
|
# Mock form fields with changed active state
|
||||||
|
mock_ip_input = Mock()
|
||||||
|
mock_ip_input.value = "127.0.0.1"
|
||||||
|
mock_hostname_input = Mock()
|
||||||
|
mock_hostname_input.value = "localhost"
|
||||||
|
mock_comment_input = Mock()
|
||||||
|
mock_comment_input.value = ""
|
||||||
|
mock_checkbox = Mock()
|
||||||
|
mock_checkbox.value = False # Changed active state
|
||||||
|
|
||||||
|
def mock_query_side_effect(selector, widget_type=None):
|
||||||
|
if selector == "#ip-input":
|
||||||
|
return mock_ip_input
|
||||||
|
elif selector == "#hostname-input":
|
||||||
|
return mock_hostname_input
|
||||||
|
elif selector == "#comment-input":
|
||||||
|
return mock_comment_input
|
||||||
|
elif selector == "#active-checkbox":
|
||||||
|
return mock_checkbox
|
||||||
|
|
||||||
|
mock_query_one.side_effect = mock_query_side_effect
|
||||||
|
|
||||||
|
assert app.has_entry_changes()
|
||||||
|
|
||||||
|
def test_exit_edit_entry_mode(self, app):
|
||||||
|
"""Test exit_edit_entry_mode cleans up properly."""
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
app.original_entry_values = {"test": "data"}
|
||||||
|
app.update_entry_details = Mock()
|
||||||
|
app.query_one = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
|
||||||
|
mock_table = Mock()
|
||||||
|
app.query_one.return_value = mock_table
|
||||||
|
|
||||||
|
app.exit_edit_entry_mode()
|
||||||
|
|
||||||
|
assert not app.entry_edit_mode
|
||||||
|
assert app.original_entry_values is None
|
||||||
|
app.update_entry_details.assert_called_once()
|
||||||
|
mock_table.focus.assert_called_once()
|
||||||
|
app.update_status.assert_called_once_with("Exited entry edit mode")
|
Loading…
Add table
Add a link
Reference in a new issue