feat: 8.0 - Frontend - Create habit form modal
This commit is contained in:
@@ -83,6 +83,121 @@
|
||||
flex-direction: column;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -143,6 +258,36 @@
|
||||
</div>
|
||||
</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>
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
@@ -171,14 +316,90 @@
|
||||
initTheme();
|
||||
lucide.createIcons();
|
||||
|
||||
// Placeholder function for add habit modal (to be implemented in future stories)
|
||||
// Modal functions
|
||||
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)
|
||||
async function loadHabits() {
|
||||
// Will be implemented when API is integrated
|
||||
// Will be implemented in next story
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
342
dashboard/test_habits_modal.py
Normal file
342
dashboard/test_habits_modal.py
Normal 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()
|
||||
Reference in New Issue
Block a user