feat: 8.0 - Frontend - Create habit form modal

This commit is contained in:
Echo
2026-02-10 12:10:33 +00:00
parent b672947ac1
commit 97af2ae3c3
2 changed files with 566 additions and 3 deletions

View File

@@ -83,6 +83,121 @@
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
} }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: var(--space-4);
}
.form-group {
margin-bottom: var(--space-4);
}
.form-label {
display: block;
font-size: var(--text-sm);
font-weight: 500;
margin-bottom: var(--space-1);
color: var(--text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-top: var(--space-5);
}
/* Radio group */
.radio-group {
display: flex;
gap: var(--space-3);
}
.radio-option {
flex: 1;
}
.radio-option input[type="radio"] {
display: none;
}
.radio-label {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
}
.radio-option input[type="radio"]:checked + .radio-label {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.radio-label:hover {
border-color: var(--accent);
}
/* Toast */
.toast {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
opacity: 0;
transition: all var(--transition-base);
z-index: 1001;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
</style> </style>
</head> </head>
<body> <body>
@@ -143,6 +258,36 @@
</div> </div>
</main> </main>
<!-- Add Habit Modal -->
<div class="modal-overlay" id="habitModal">
<div class="modal">
<h2 class="modal-title">Obișnuință nouă</h2>
<div class="form-group">
<label class="form-label">Nume *</label>
<input type="text" class="input" id="habitName" placeholder="ex: Bazin, Sală, Meditație...">
</div>
<div class="form-group">
<label class="form-label">Frecvență</label>
<div class="radio-group">
<div class="radio-option">
<input type="radio" name="frequency" id="freqDaily" value="daily" checked>
<label for="freqDaily" class="radio-label">Zilnic</label>
</div>
<div class="radio-option">
<input type="radio" name="frequency" id="freqWeekly" value="weekly">
<label for="freqWeekly" class="radio-label">Săptămânal</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="hideHabitModal()">Anulează</button>
<button class="btn btn-primary" id="habitCreateBtn" onclick="createHabit()" disabled>Creează</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script> <script>
// Theme management // Theme management
function initTheme() { function initTheme() {
@@ -171,14 +316,90 @@
initTheme(); initTheme();
lucide.createIcons(); lucide.createIcons();
// Placeholder function for add habit modal (to be implemented in future stories) // Modal functions
function showAddHabitModal() { function showAddHabitModal() {
alert('Funcționalitatea de adăugare obișnuință va fi implementată în următoarea fază!'); const modal = document.getElementById('habitModal');
const nameInput = document.getElementById('habitName');
const createBtn = document.getElementById('habitCreateBtn');
// Reset form
nameInput.value = '';
document.getElementById('freqDaily').checked = true;
createBtn.disabled = true;
// Show modal
modal.classList.add('active');
nameInput.focus();
}
function hideHabitModal() {
const modal = document.getElementById('habitModal');
modal.classList.remove('active');
}
// Form validation - enable/disable Create button based on name input
document.addEventListener('DOMContentLoaded', () => {
const nameInput = document.getElementById('habitName');
const createBtn = document.getElementById('habitCreateBtn');
nameInput.addEventListener('input', () => {
const name = nameInput.value.trim();
createBtn.disabled = name.length === 0;
});
// Allow Enter key to submit if button is enabled
nameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !createBtn.disabled) {
createHabit();
}
});
});
// Create habit
async function createHabit() {
const nameInput = document.getElementById('habitName');
const name = nameInput.value.trim();
const frequency = document.querySelector('input[name="frequency"]:checked').value;
if (!name) {
showToast('Te rog introdu un nume pentru obișnuință');
return;
}
try {
const response = await fetch('/api/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, frequency })
});
if (response.ok) {
hideHabitModal();
showToast('Obișnuință creată cu succes!');
loadHabits();
} else {
const error = await response.text();
showToast('Eroare la crearea obișnuinței: ' + error);
}
} catch (error) {
showToast('Eroare la conectarea cu serverul');
console.error(error);
}
}
// Toast notification
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
} }
// Load habits (placeholder for future API integration) // Load habits (placeholder for future API integration)
async function loadHabits() { async function loadHabits() {
// Will be implemented when API is integrated // Will be implemented in next story
} }
</script> </script>
</body> </body>

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Tests for habit creation modal in habits.html
Validates modal structure, form elements, buttons, and styling
"""
import os
import sys
from html.parser import HTMLParser
HABITS_HTML_PATH = 'dashboard/habits.html'
class ModalParser(HTMLParser):
"""Parser to extract modal elements from HTML"""
def __init__(self):
super().__init__()
self.in_modal = False
self.in_modal_title = False
self.in_form_label = False
self.in_button = False
self.modal_title = None
self.form_labels = []
self.name_input_attrs = None
self.radio_buttons = []
self.buttons = []
self.radio_labels = []
self.in_radio_label = False
self.current_radio_label = None
self.modal_overlay_found = False
self.modal_div_found = False
self.toast_found = False
def handle_starttag(self, tag, attrs):
attrs_dict = dict(attrs)
# Check for modal overlay
if tag == 'div' and attrs_dict.get('id') == 'habitModal':
self.modal_overlay_found = True
if 'modal-overlay' in attrs_dict.get('class', ''):
self.in_modal = True
# Check for modal div
if self.in_modal and tag == 'div' and 'modal' in attrs_dict.get('class', ''):
self.modal_div_found = True
# Check for modal title
if self.in_modal and tag == 'h2' and 'modal-title' in attrs_dict.get('class', ''):
self.in_modal_title = True
# Check for form labels
if self.in_modal and tag == 'label' and 'form-label' in attrs_dict.get('class', ''):
self.in_form_label = True
# Check for name input
if self.in_modal and tag == 'input' and attrs_dict.get('id') == 'habitName':
self.name_input_attrs = attrs_dict
# Check for radio buttons
if self.in_modal and tag == 'input' and attrs_dict.get('type') == 'radio':
self.radio_buttons.append(attrs_dict)
# Check for radio labels
if self.in_modal and tag == 'label' and 'radio-label' in attrs_dict.get('class', ''):
self.in_radio_label = True
self.current_radio_label = attrs_dict.get('for', '')
# Check for buttons
if self.in_modal and tag == 'button':
self.buttons.append(attrs_dict)
self.in_button = True
# Check for toast
if tag == 'div' and attrs_dict.get('id') == 'toast':
self.toast_found = True
def handle_endtag(self, tag):
if tag == 'h2':
self.in_modal_title = False
if tag == 'label':
self.in_form_label = False
self.in_radio_label = False
if tag == 'button':
self.in_button = False
if tag == 'div' and self.in_modal:
# Don't close modal state until we're sure we've left it
pass
def handle_data(self, data):
if self.in_modal_title and not self.modal_title:
self.modal_title = data.strip()
if self.in_form_label:
self.form_labels.append(data.strip())
if self.in_radio_label:
self.radio_labels.append({'for': self.current_radio_label, 'text': data.strip()})
def test_modal_structure():
"""Test modal HTML structure exists"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
parser = ModalParser()
parser.feed(content)
# Check modal overlay exists
assert parser.modal_overlay_found, "Modal overlay with id='habitModal' not found"
# Check modal container exists
assert parser.modal_div_found, "Modal div with class='modal' not found"
# Check modal title
assert parser.modal_title is not None, "Modal title not found"
assert 'nou' in parser.modal_title.lower(), f"Modal title should mention 'nou', got: {parser.modal_title}"
print("✓ Modal structure exists")
def test_name_input_field():
"""Test habit name input field exists and is required"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
parser = ModalParser()
parser.feed(content)
# Find name input
assert parser.name_input_attrs is not None, "Name input field with id='habitName' not found"
# Check it's a text input
assert parser.name_input_attrs.get('type') == 'text', "Name input should be type='text'"
# Check it has class 'input'
assert 'input' in parser.name_input_attrs.get('class', ''), "Name input should have class='input'"
# Check it has placeholder
assert parser.name_input_attrs.get('placeholder'), "Name input should have placeholder"
# Check label exists and mentions required (*)
found_required_label = any('*' in label for label in parser.form_labels)
assert found_required_label, "Should have a form label with * indicating required field"
print("✓ Name input field exists with required indicator")
def test_frequency_radio_buttons():
"""Test frequency radio buttons exist with daily and weekly options"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
parser = ModalParser()
parser.feed(content)
# Check we have radio buttons
assert len(parser.radio_buttons) >= 2, f"Should have at least 2 radio buttons, found {len(parser.radio_buttons)}"
# Find daily radio button
daily_radio = next((r for r in parser.radio_buttons if r.get('value') == 'daily'), None)
assert daily_radio is not None, "Daily radio button with value='daily' not found"
assert daily_radio.get('name') == 'frequency', "Daily radio should have name='frequency'"
# Check for 'checked' attribute - it may be None or empty string when present
assert 'checked' in daily_radio, "Daily radio should be checked by default"
# Find weekly radio button
weekly_radio = next((r for r in parser.radio_buttons if r.get('value') == 'weekly'), None)
assert weekly_radio is not None, "Weekly radio button with value='weekly' not found"
assert weekly_radio.get('name') == 'frequency', "Weekly radio should have name='frequency'"
# Check labels exist with Romanian text
daily_label = next((l for l in parser.radio_labels if 'zilnic' in l['text'].lower()), None)
assert daily_label is not None, "Daily label with 'Zilnic' text not found"
weekly_label = next((l for l in parser.radio_labels if 'săptămânal' in l['text'].lower()), None)
assert weekly_label is not None, "Weekly label with 'Săptămânal' text not found"
print("✓ Frequency radio buttons exist with daily (default) and weekly options")
def test_modal_buttons():
"""Test modal has Cancel and Create buttons"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
parser = ModalParser()
parser.feed(content)
# Check we have 2 buttons
assert len(parser.buttons) >= 2, f"Should have at least 2 buttons, found {len(parser.buttons)}"
# Check Cancel button
cancel_btn = next((b for b in parser.buttons if 'btn-secondary' in b.get('class', '')), None)
assert cancel_btn is not None, "Cancel button with class='btn-secondary' not found"
assert 'hideHabitModal' in cancel_btn.get('onclick', ''), "Cancel should call hideHabitModal"
# Check Create button
create_btn = next((b for b in parser.buttons if b.get('id') == 'habitCreateBtn'), None)
assert create_btn is not None, "Create button with id='habitCreateBtn' not found"
assert 'btn-primary' in create_btn.get('class', ''), "Create button should have class='btn-primary'"
assert 'createHabit' in create_btn.get('onclick', ''), "Create should call createHabit"
# Check for 'disabled' attribute - it may be None or empty string when present
assert 'disabled' in create_btn, "Create button should start disabled"
# Check button text in content
assert 'anulează' in content.lower(), "Cancel button should say 'Anulează'"
assert 'creează' in content.lower(), "Create button should say 'Creează'"
print("✓ Modal has Cancel and Create buttons with correct attributes")
def test_add_button_triggers_modal():
"""Test that add habit button calls showAddHabitModal"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
# Find add habit button
assert 'class="add-habit-btn"' in content, "Add habit button not found"
assert 'showAddHabitModal()' in content, "Add button should call showAddHabitModal()"
print("✓ Add habit button calls showAddHabitModal()")
def test_modal_styling():
"""Test modal uses dashboard modal styling patterns"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
# Check key modal classes exist in CSS
required_styles = [
'.modal-overlay',
'.modal-overlay.active',
'.modal {',
'.modal-title',
'.modal-actions',
'.form-group',
'.form-label',
'.radio-group',
]
for style in required_styles:
assert style in content, f"Modal style '{style}' not found"
# Check modal uses CSS variables (dashboard pattern)
assert 'var(--bg-base)' in content, "Modal should use --bg-base"
assert 'var(--border)' in content, "Modal should use --border"
assert 'var(--accent)' in content, "Modal should use --accent"
print("✓ Modal uses dashboard modal styling patterns")
def test_javascript_functions():
"""Test JavaScript functions for modal interaction exist"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
# Check essential functions exist
assert 'function showAddHabitModal()' in content, "showAddHabitModal function not found"
assert 'function hideHabitModal()' in content, "hideHabitModal function not found"
assert 'async function createHabit()' in content or 'function createHabit()' in content, "createHabit function not found"
# Check form validation logic
assert "createBtn.disabled" in content, "Create button disable logic not found"
assert "nameInput.value.trim()" in content, "Name trim validation not found"
# Check modal show/hide logic
assert "modal.classList.add('active')" in content, "Modal show logic not found"
assert "modal.classList.remove('active')" in content, "Modal hide logic not found"
# Check API integration
assert "fetch('/api/habits'" in content, "API call to /api/habits not found"
assert "method: 'POST'" in content, "POST method not found"
print("✓ JavaScript functions for modal interaction exist")
def test_toast_notification():
"""Test toast notification element exists"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
parser = ModalParser()
parser.feed(content)
# Find toast element
assert parser.toast_found, "Toast notification element with id='toast' not found"
# Check toast styles exist
assert '.toast' in content, "Toast styles not found"
assert '.toast.show' in content, "Toast show state styles not found"
# Check showToast function exists
assert 'function showToast(' in content, "showToast function not found"
print("✓ Toast notification element exists")
def test_form_validation_event_listeners():
"""Test form validation with event listeners"""
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
content = f.read()
# Check for DOMContentLoaded event listener
assert "addEventListener('DOMContentLoaded'" in content or "DOMContentLoaded" in content, \
"Should have DOMContentLoaded event listener"
# Check for input event listener on name field
assert "addEventListener('input'" in content, "Should have input event listener for validation"
# Check for Enter key handling
assert "addEventListener('keypress'" in content or "e.key === 'Enter'" in content, \
"Should handle Enter key submission"
print("✓ Form validation event listeners exist")
def run_tests():
"""Run all tests"""
tests = [
test_modal_structure,
test_name_input_field,
test_frequency_radio_buttons,
test_modal_buttons,
test_add_button_triggers_modal,
test_modal_styling,
test_javascript_functions,
test_toast_notification,
test_form_validation_event_listeners,
]
print("Running habit modal tests...\n")
failed = []
for test in tests:
try:
test()
except AssertionError as e:
print(f"{test.__name__}: {e}")
failed.append(test.__name__)
except Exception as e:
print(f"{test.__name__}: Unexpected error: {e}")
failed.append(test.__name__)
print(f"\n{'='*50}")
if failed:
print(f"FAILED: {len(failed)} test(s) failed")
for name in failed:
print(f" - {name}")
sys.exit(1)
else:
print(f"SUCCESS: All {len(tests)} tests passed!")
sys.exit(0)
if __name__ == '__main__':
run_tests()