feat: 16.0 - Frontend - Delete habit with confirmation
This commit is contained in:
@@ -153,6 +153,36 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Delete button */
|
||||
.habit-delete-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.habit-delete-btn:hover {
|
||||
border-color: var(--text-danger);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.habit-delete-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.habit-delete-btn:hover svg {
|
||||
color: var(--text-danger);
|
||||
}
|
||||
|
||||
/* Habit checkbox */
|
||||
.habit-checkbox {
|
||||
width: 32px;
|
||||
@@ -359,6 +389,58 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Delete confirmation modal */
|
||||
.confirm-modal {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.confirm-modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.confirm-modal-message {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.confirm-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--text-danger);
|
||||
color: white;
|
||||
border: 1px solid var(--text-danger);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.main {
|
||||
@@ -522,6 +604,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal-overlay" id="deleteModal">
|
||||
<div class="confirm-modal">
|
||||
<h2 class="confirm-modal-title">Ștergi obișnuința?</h2>
|
||||
<p class="confirm-modal-message" id="deleteModalMessage">
|
||||
Ștergi obișnuința <strong id="deleteHabitName"></strong>?
|
||||
</p>
|
||||
<div class="confirm-modal-actions">
|
||||
<button class="btn btn-secondary" onclick="hideDeleteModal()">Anulează</button>
|
||||
<button class="btn btn-danger" id="confirmDeleteBtn" onclick="confirmDelete()">Șterge</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
@@ -731,6 +827,9 @@
|
||||
<span id="streak-${habit.id}">${habit.streak || 0}</span>
|
||||
<span>🔥</span>
|
||||
</div>
|
||||
<button class="habit-delete-btn" onclick="showDeleteModal('${habit.id}', '${escapeHtml(habit.name).replace(/'/g, "'")}')">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
return card;
|
||||
@@ -807,6 +906,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Delete habit functions
|
||||
let habitToDelete = null;
|
||||
|
||||
function showDeleteModal(habitId, habitName) {
|
||||
habitToDelete = habitId;
|
||||
const modal = document.getElementById('deleteModal');
|
||||
const nameElement = document.getElementById('deleteHabitName');
|
||||
|
||||
// Decode HTML entities for display
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = habitName;
|
||||
nameElement.textContent = tempDiv.textContent;
|
||||
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function hideDeleteModal() {
|
||||
const modal = document.getElementById('deleteModal');
|
||||
modal.classList.remove('active');
|
||||
habitToDelete = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!habitToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
|
||||
// Disable button during deletion
|
||||
deleteBtn.disabled = true;
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Se șterge...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/habits/${habitToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete habit');
|
||||
}
|
||||
|
||||
hideDeleteModal();
|
||||
showToast('Obișnuință ștearsă cu succes');
|
||||
loadHabits();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting habit:', error);
|
||||
showToast('Eroare la ștergerea obișnuinței. Încearcă din nou.');
|
||||
|
||||
// Re-enable button on error
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Load habits on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadHabits();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"lastUpdated": "2026-02-10T13:19:32.381583",
|
||||
"habits": [
|
||||
{
|
||||
"id": "habit-test1",
|
||||
"name": "Test Habit",
|
||||
"id": "habit-1770729572381",
|
||||
"name": "Water Plants",
|
||||
"frequency": "daily",
|
||||
"createdAt": "2026-02-01T10:00:00Z",
|
||||
"createdAt": "2026-02-10T13:19:32.381005",
|
||||
"completions": [
|
||||
"2026-02-10"
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-02-10T10:00:00Z"
|
||||
]
|
||||
}
|
||||
274
dashboard/test_habits_delete_ui.py
Normal file
274
dashboard/test_habits_delete_ui.py
Normal file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test suite for Story 16.0: Frontend - Delete habit with confirmation
|
||||
Tests the delete button and confirmation modal functionality.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
def test_file_exists():
|
||||
"""Test that habits.html exists"""
|
||||
try:
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print("FAIL: habits.html not found")
|
||||
return False
|
||||
|
||||
|
||||
def test_delete_button_css():
|
||||
"""AC1: Tests delete button styling (trash icon button using lucide)"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for delete button CSS class
|
||||
if '.habit-delete-btn' not in content:
|
||||
print("FAIL: .habit-delete-btn CSS class not found")
|
||||
return False
|
||||
|
||||
# Check for proper styling (size, border, hover state)
|
||||
css_pattern = r'\.habit-delete-btn\s*\{[^}]*width:\s*32px[^}]*height:\s*32px'
|
||||
if not re.search(css_pattern, content, re.DOTALL):
|
||||
print("FAIL: Delete button sizing not found (32x32px)")
|
||||
return False
|
||||
|
||||
# Check for hover state with danger color
|
||||
if '.habit-delete-btn:hover' not in content:
|
||||
print("FAIL: Delete button hover state not found")
|
||||
return False
|
||||
|
||||
if 'var(--text-danger)' not in content:
|
||||
print("FAIL: Danger color not used for delete button")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_delete_button_in_card():
|
||||
"""AC1: Tests that habit card includes trash icon button"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for trash-2 icon (lucide) in createHabitCard
|
||||
if 'trash-2' not in content:
|
||||
print("FAIL: trash-2 icon not found")
|
||||
return False
|
||||
|
||||
# Check for delete button in card HTML with onclick handler
|
||||
pattern = r'habit-delete-btn.*onclick.*showDeleteModal'
|
||||
if not re.search(pattern, content, re.DOTALL):
|
||||
print("FAIL: Delete button with onclick handler not found in card")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_confirmation_modal_structure():
|
||||
"""AC2: Tests confirmation modal 'Ștergi obișnuința {name}?'"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for delete modal element
|
||||
if 'id="deleteModal"' not in content:
|
||||
print("FAIL: deleteModal element not found")
|
||||
return False
|
||||
|
||||
# Check for Romanian confirmation message
|
||||
if 'Ștergi obișnuința' not in content:
|
||||
print("FAIL: Romanian confirmation message not found")
|
||||
return False
|
||||
|
||||
# Check for habit name placeholder
|
||||
if 'id="deleteHabitName"' not in content:
|
||||
print("FAIL: deleteHabitName element for dynamic habit name not found")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_confirmation_buttons():
|
||||
"""AC3 & AC4: Tests Cancel and Delete buttons with correct styling"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for Cancel button
|
||||
if 'onclick="hideDeleteModal()"' not in content:
|
||||
print("FAIL: Cancel button with hideDeleteModal() not found")
|
||||
return False
|
||||
|
||||
# Check for Delete button
|
||||
if 'onclick="confirmDelete()"' not in content:
|
||||
print("FAIL: Delete button with confirmDelete() not found")
|
||||
return False
|
||||
|
||||
# AC4: Check for destructive red styling (btn-danger class)
|
||||
if '.btn-danger' not in content:
|
||||
print("FAIL: .btn-danger CSS class not found")
|
||||
return False
|
||||
|
||||
# Check that btn-danger uses danger color
|
||||
css_pattern = r'\.btn-danger\s*\{[^}]*background:\s*var\(--text-danger\)'
|
||||
if not re.search(css_pattern, content, re.DOTALL):
|
||||
print("FAIL: btn-danger does not use danger color")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_delete_api_call():
|
||||
"""AC5: Tests DELETE API call and list removal on confirm"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for confirmDelete function
|
||||
if 'async function confirmDelete()' not in content:
|
||||
print("FAIL: confirmDelete async function not found")
|
||||
return False
|
||||
|
||||
# Check for DELETE method call to API
|
||||
pattern = r"method:\s*['\"]DELETE['\"]"
|
||||
if not re.search(pattern, content):
|
||||
print("FAIL: DELETE method not found in confirmDelete")
|
||||
return False
|
||||
|
||||
# Check for API endpoint with habitToDelete variable
|
||||
pattern = r"/api/habits/\$\{habitToDelete\}"
|
||||
if not re.search(pattern, content):
|
||||
print("FAIL: DELETE endpoint /api/habits/{id} not found")
|
||||
return False
|
||||
|
||||
# Check for loadHabits() call after successful deletion (removes from list)
|
||||
if 'loadHabits()' not in content:
|
||||
print("FAIL: loadHabits() not called after deletion (list won't refresh)")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_error_handling():
|
||||
"""AC6: Tests error message display if delete fails"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for error handling in confirmDelete
|
||||
pattern = r'catch\s*\(error\)\s*\{[^}]*showToast'
|
||||
if not re.search(pattern, content, re.DOTALL):
|
||||
print("FAIL: Error handling with showToast not found in confirmDelete")
|
||||
return False
|
||||
|
||||
# Check for error message
|
||||
if 'Eroare la ștergerea obișnuinței' not in content:
|
||||
print("FAIL: Delete error message not found")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_modal_functions():
|
||||
"""Tests showDeleteModal and hideDeleteModal functions"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for showDeleteModal function
|
||||
if 'function showDeleteModal(' not in content:
|
||||
print("FAIL: showDeleteModal function not found")
|
||||
return False
|
||||
|
||||
# Check for hideDeleteModal function
|
||||
if 'function hideDeleteModal(' not in content:
|
||||
print("FAIL: hideDeleteModal function not found")
|
||||
return False
|
||||
|
||||
# Check for habitToDelete variable tracking
|
||||
if 'habitToDelete' not in content:
|
||||
print("FAIL: habitToDelete tracking variable not found")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_modal_show_hide_logic():
|
||||
"""Tests modal active class toggle for show/hide"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for classList.add('active') in showDeleteModal
|
||||
pattern = r'showDeleteModal[^}]*classList\.add\(["\']active["\']\)'
|
||||
if not re.search(pattern, content, re.DOTALL):
|
||||
print("FAIL: Modal show logic (classList.add('active')) not found")
|
||||
return False
|
||||
|
||||
# Check for classList.remove('active') in hideDeleteModal
|
||||
pattern = r'hideDeleteModal[^}]*classList\.remove\(["\']active["\']\)'
|
||||
if not re.search(pattern, content, re.DOTALL):
|
||||
print("FAIL: Modal hide logic (classList.remove('active')) not found")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_acceptance_criteria_summary():
|
||||
"""AC7: Summary test verifying all acceptance criteria"""
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
checks = {
|
||||
'AC1: Trash icon button': 'trash-2' in content and '.habit-delete-btn' in content,
|
||||
'AC2: Confirmation modal message': 'Ștergi obișnuința' in content and 'id="deleteHabitName"' in content,
|
||||
'AC3: Cancel and Delete buttons': 'hideDeleteModal()' in content and 'confirmDelete()' in content,
|
||||
'AC4: Red destructive style': '.btn-danger' in content and 'var(--text-danger)' in content,
|
||||
'AC5: DELETE endpoint call': 'method:' in content and 'DELETE' in content and '/api/habits/' in content,
|
||||
'AC6: Error handling': 'catch (error)' in content and 'Eroare la ștergerea' in content,
|
||||
'AC7: Delete interaction tests pass': True # This test itself
|
||||
}
|
||||
|
||||
all_passed = all(checks.values())
|
||||
|
||||
if not all_passed:
|
||||
print("FAIL: Not all acceptance criteria met:")
|
||||
for criterion, passed in checks.items():
|
||||
if not passed:
|
||||
print(f" ✗ {criterion}")
|
||||
|
||||
return all_passed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
tests = [
|
||||
("File exists", test_file_exists),
|
||||
("Delete button CSS styling", test_delete_button_css),
|
||||
("Delete button in habit card (trash icon)", test_delete_button_in_card),
|
||||
("Confirmation modal structure", test_confirmation_modal_structure),
|
||||
("Confirmation buttons (Cancel & Delete)", test_confirmation_buttons),
|
||||
("DELETE API call on confirm", test_delete_api_call),
|
||||
("Error handling for failed delete", test_error_handling),
|
||||
("Modal show/hide functions", test_modal_functions),
|
||||
("Modal active class toggle logic", test_modal_show_hide_logic),
|
||||
("All acceptance criteria summary", test_acceptance_criteria_summary),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
total = len(tests)
|
||||
|
||||
print("Running Story 16.0 tests (Frontend - Delete habit with confirmation)...\n")
|
||||
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
result = test_func()
|
||||
if result:
|
||||
print(f"✓ {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ {name}")
|
||||
except Exception as e:
|
||||
print(f"✗ {name} (exception: {e})")
|
||||
|
||||
print(f"\n{passed}/{total} tests passed")
|
||||
|
||||
if passed == total:
|
||||
print("✓ All tests passed!")
|
||||
exit(0)
|
||||
else:
|
||||
print(f"✗ {total - passed} test(s) failed")
|
||||
exit(1)
|
||||
38
progress.txt
38
progress.txt
@@ -578,3 +578,41 @@ NEXT STEPS:
|
||||
Files modified:
|
||||
- dashboard/habits.html (enhanced mobile CSS, added input attributes)
|
||||
- dashboard/test_habits_mobile.py (created)
|
||||
|
||||
[✓] Story 15.0: Backend - Delete habit endpoint
|
||||
Commit: 0f9c0de
|
||||
Date: 2026-02-10
|
||||
|
||||
Implementation:
|
||||
- Added do_DELETE method to api.py for handling DELETE requests
|
||||
- Route: DELETE /api/habits/{id} deletes habit by ID
|
||||
- Extracts habit ID from URL path (/api/habits/{id})
|
||||
- Returns 404 if habit not found in habits.json
|
||||
- Removes habit from habits array using list.pop(index)
|
||||
- Updates lastUpdated timestamp after deletion
|
||||
- Returns 200 with success message, including deleted habit ID
|
||||
- Graceful error handling for missing/corrupt habits.json (returns 404)
|
||||
- Follows existing API patterns (similar to handle_habits_check)
|
||||
- Error responses include descriptive error messages
|
||||
|
||||
Tests:
|
||||
- Created dashboard/test_habits_delete.py with 6 comprehensive tests
|
||||
- Tests for habit removal from habits.json file (AC1)
|
||||
- Tests for 200 status with success message response (AC2)
|
||||
- Tests for 404 when habit not found (AC3)
|
||||
- Tests for lastUpdated timestamp update (AC4)
|
||||
- Tests for edge cases: deleting already deleted habit, invalid paths
|
||||
- Tests for graceful handling when habits.json is missing
|
||||
- All 6 tests pass ✓ (AC5)
|
||||
- All previous tests (schema, GET, POST, streak, check) still pass ✓
|
||||
|
||||
Files modified:
|
||||
- dashboard/api.py (added do_DELETE method and handle_habits_delete)
|
||||
- dashboard/test_habits_delete.py (created)
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
NEXT STEPS:
|
||||
- Continue with remaining 2 stories (16.0, 17.0)
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Reference in New Issue
Block a user