feat(admin): replace prompt() with Bootstrap modals for CRUD operations
Replace browser prompt()-based editing with proper Bootstrap 5 modal dialogs for testimonials, services, FAQs, and leads. This provides better UX with form validation, structured input fields, and i18n support (ES/RU) instead of raw prompt dialogs. - Add testimonialModal, serviceModal, faqModal, leadModal to admin.html - Add show*/save* methods in admin.js for each entity type - Wire leads.html 'Add lead' button to leadModal - Add modal JS modules (FAQModal, LeadModal, ServiceModal) - Add unit and e2e tests for modals and API client
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -49,3 +49,14 @@ artifact-*.html# Database files (generated)
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
|
||||
# Runtime artifacts
|
||||
cookies.txt
|
||||
.server.pid
|
||||
EOF
|
||||
|
||||
# Test reports (generated)
|
||||
tests/reports/
|
||||
|
||||
# Visual test screenshots (generated)
|
||||
tests/visual/
|
||||
|
||||
186
ADMIN_PANEL_PLAN.md
Normal file
186
ADMIN_PANEL_PLAN.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# План доработки административного раздела TenerifeProp
|
||||
|
||||
## Результаты визуального тестирования
|
||||
|
||||
**Дата тестирования:** 2026-05-15
|
||||
**Скрипт:** `tests/scripts/admin-panel-deep-test.js`
|
||||
**Milestone:** [#68 Admin Panel — Fix Broken Functionality](https://git.softuniq.eu/UniqueSoft/TenerifeProp/milestone/68)
|
||||
|
||||
---
|
||||
|
||||
## Зеленая зона (работает)
|
||||
|
||||
| Раздел | Функционал | Статус |
|
||||
|--------|-----------|--------|
|
||||
| Dashboard | Стат-карточки загружаются из API | ✅ |
|
||||
| Dashboard | Быстрые фильтры Semana/Mes/Ano | ✅ |
|
||||
| Propiedades | Карточки отображаются, фильтры работают | ✅ |
|
||||
| Propiedades | **"Añadir propiedad" открывает модал** | ✅ |
|
||||
| Propiedades | Кнопки Ver/Editar/Eliminar на карточках | ✅ |
|
||||
| Leads | Таблица загружается, статусы отображаются | ✅ |
|
||||
| Usuarios | **"Añadir usuario" открывает модал** | ✅ |
|
||||
| Settings | Поля загружаются из API | ✅ |
|
||||
| Sidebar | Навигация между разделами | ✅ |
|
||||
| Topbar | Переключение языка ES/RU | ✅ |
|
||||
| Auth | Login/Logout работает | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Красная зона (не работает)
|
||||
|
||||
### 1. Testimonios — кнопка "Añadir testimonio" бездейственна
|
||||
- **Issue:** [#30](https://git.softuniq.eu/UniqueSoft/TenerifeProp/issues/30)
|
||||
- **Проблема:** Кнопка не имеет `onclick` или `data-bs-toggle`. Нет модального окна для создания.
|
||||
- **Текущий код:** `editTestimonial()` использует `prompt()` для редактирования.
|
||||
- **Что нужно:**
|
||||
1. Добавить `testimonialModal` в `admin.html`
|
||||
2. Создать `showTestimonialModal(property=null)` в `admin.js`
|
||||
3. Создать `saveTestimonial()` с вызовом `API.createTestimonial()` / `API.updateTestimonial()`
|
||||
4. Привязать кнопку "Añadir testimonio" к `showTestimonialModal()`
|
||||
|
||||
### 2. Servicios — кнопка "Añadir servicio" бездейственна
|
||||
- **Issue:** [#31](https://git.softuniq.eu/UniqueSoft/TenerifeProp/issues/31)
|
||||
- **Проблема:** Нет модального окна для создания. Кнопка — простой `<button>` без обработчика.
|
||||
- **Текущий код:** `editService()` использует `prompt()`.
|
||||
- **Что нужно:**
|
||||
1. Добавить `serviceModal` в `admin.html`
|
||||
2. Создать `showServiceModal()` / `saveService()` в `admin.js`
|
||||
3. Привязать кнопку
|
||||
|
||||
### 3. FAQ — кнопка "Añadir pregunta" бездейственна
|
||||
- **Issue:** [#32](https://git.softuniq.eu/UniqueSoft/TenerifeProp/issues/32)
|
||||
- **Проблема:** Нет модального окна для создания.
|
||||
- **Текущий код:** `editFAQ()` использует `prompt()`.
|
||||
- **Что нужно:**
|
||||
1. Добавить `faqModal` в `admin.html`
|
||||
2. Создать `showFAQModal()` / `saveFAQ()` в `admin.js`
|
||||
3. Привязать кнопку
|
||||
|
||||
### 4. Leads — кнопка "Añadir lead manualmente" бездейственна
|
||||
- **Issue:** [#33](https://git.softuniq.eu/UniqueSoft/TenerifeProp/issues/33)
|
||||
- **Проблема:** Кнопка не имеет обработчика.
|
||||
- **Текущий код:** Нет функций для создания lead из админки.
|
||||
- **Что нужно:**
|
||||
1. Добавить `leadModal` в `admin.html`
|
||||
2. Создать `showLeadModal()` / `saveLead()` в `admin.js`
|
||||
3. API endpoint `POST /api/admin/leads` уже существует в бэкенде
|
||||
4. Привязать кнопку
|
||||
|
||||
### 5. Dashboard — графики пустые
|
||||
- **Issue:** [#34](https://git.softuniq.eu/UniqueSoft/TenerifeProp/issues/34)
|
||||
- **Проблема:** Блоки "Rendimiento mensual", "Fuentes de tráfico", "Propiedades por tipo", "Estado de leads", "Top 5 propiedades" — пустые.
|
||||
- **Возможные причины:**
|
||||
- Chart.js не инициализируется из-за отсутствия `<canvas>` элементов
|
||||
- Данные приходят пустыми из API
|
||||
- Ошибка в `initCharts()`
|
||||
- **Что нужно:**
|
||||
1. Проверить `initCharts()` в `admin.js`
|
||||
2. Проверить `<canvas id="...">` элементы в `admin.html`
|
||||
3. Проверить данные из `API.getAdminStats()` и `API.getAnalyticsCharts()`
|
||||
|
||||
### 6. Settings — не проверена полная цепочка сохранения
|
||||
- **Issue:** [#35](https://git.softuniq.eu/UniqueSoft/TenerifeProp/issues/35)
|
||||
- **Проблема:** Кнопка "Guardar cambios" присутствует, но E2E-проверка не проводилась.
|
||||
- **Что нужно:**
|
||||
1. Изменить значение → Сохранить → Перезагрузить страницу
|
||||
2. Проверить что значение сохранилось
|
||||
|
||||
---
|
||||
|
||||
## Порядок исправления (приоритет)
|
||||
|
||||
| Приоритет | Задача | Issue | Почему первым |
|
||||
|-----------|--------|-------|---------------|
|
||||
| P0 | Добавить модал для Testimonios | #30 | Контент-критично |
|
||||
| P0 | Добавить модал для Servicios | #31 | Контент-критично |
|
||||
| P0 | Добавить модал для FAQ | #32 | Контент-критично |
|
||||
| P1 | Добавить модал для Leads | #33 | Операционно важно |
|
||||
| P1 | Исправить графики Dashboard | #34 | Визуальная ценность |
|
||||
| P2 | Проверить Settings E2E | #35 | Дополнительно |
|
||||
|
||||
---
|
||||
|
||||
## Технический подход к исправлению
|
||||
|
||||
### Унифицированная структура модала
|
||||
Для всех 4 новых модалов использовать паттерн аналогичный `propertyModal`:
|
||||
|
||||
```html
|
||||
<div class="modal fade" id="{name}Modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">...</div>
|
||||
<div class="modal-body">...</form>...</div>
|
||||
<div class="modal-footer">
|
||||
<button data-bs-dismiss="modal">Cancelar</button>
|
||||
<button onclick="admin.save{Name}()">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Унифицированная структура JS-функций
|
||||
```javascript
|
||||
show{Name}Modal(item = null) {
|
||||
const modal = document.getElementById('{name}Modal')
|
||||
if (!modal) return
|
||||
// Reset or fill form
|
||||
modal._editId = item ? item.id : null
|
||||
new bootstrap.Modal(modal).show()
|
||||
}
|
||||
|
||||
async save{Name}() {
|
||||
const modal = document.getElementById('{name}Modal')
|
||||
const data = {} // collect from form
|
||||
const res = modal._editId
|
||||
? await API.update{Name}(modal._editId, data)
|
||||
: await API.create{Name}(data)
|
||||
if (res.success) {
|
||||
bootstrap.Modal.getInstance(modal)?.hide()
|
||||
this.load{Name}s()
|
||||
this.showNotification('Guardado', 'success')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Client (api.js) — проверить наличие методов
|
||||
- `API.createTestimonial(data)` — должен POST `/api/admin/testimonials`
|
||||
- `API.createService(data)` — должен POST `/api/admin/services`
|
||||
- `API.createFAQ(data)` — должен POST `/api/admin/faq`
|
||||
- `API.createLead(data)` — должен POST `/api/admin/leads` (уже есть)
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. **Phase 1 — Модалы создания** (Issues #30, #31, #32, #33)
|
||||
- @lead-developer создаёт HTML модалы
|
||||
- @lead-developer добавляет JS функции
|
||||
- @code-skeptic ревью
|
||||
|
||||
2. **Phase 2 — Графики Dashboard** (Issue #34)
|
||||
- @lead-developer исправляет `initCharts()`
|
||||
- @frontend-developer проверяет вёрстку canvas
|
||||
|
||||
3. **Phase 3 — Settings E2E** (Issue #35)
|
||||
- @sdet-engineer пишет тест
|
||||
- Ручная проверка если тест проходит
|
||||
|
||||
4. **Phase 4 — Финальное визуальное тестирование**
|
||||
- Перезапустить `admin-panel-deep-test.js`
|
||||
- Цель: 100% pass rate, 0 warnings
|
||||
|
||||
---
|
||||
|
||||
## Резюме
|
||||
|
||||
| Метрика | До | После (цель) |
|
||||
|---------|-----|-------------|
|
||||
| Разделов | 10 | 10 |
|
||||
| Проходят тест | 7 | 10 |
|
||||
| Предупреждения | 3 | 0 |
|
||||
| Провальные | 0 | 0 |
|
||||
| Модалов открывается | 2/6 | 6/6 |
|
||||
|
||||
**Milestone:** https://git.softuniq.eu/UniqueSoft/TenerifeProp/milestone/68
|
||||
@@ -1448,6 +1448,220 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ TESTIMONIAL MODAL ============ -->
|
||||
<div class="modal fade" id="testimonialModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-chat-quote me-2"></i>Añadir testimonio</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nombre</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="Michael Schmidt">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Ubicación</label>
|
||||
<input type="text" class="form-control" name="location" placeholder="Alemania">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Valoración (1-5)</label>
|
||||
<select class="form-select" name="rating">
|
||||
<option value="5">★★★★★</option>
|
||||
<option value="4">★★★★</option>
|
||||
<option value="3">★★★</option>
|
||||
<option value="2">★★</option>
|
||||
<option value="1">★</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Texto (ES)</label>
|
||||
<textarea class="form-control" rows="3" name="text_es" placeholder="Texto del testimonio..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Texto (RU)</label>
|
||||
<textarea class="form-control" rows="3" name="text_ru" placeholder="Текст отзыва..."></textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_approved" id="testimonialApproved" checked>
|
||||
<label class="form-check-label" for="testimonialApproved">Aprobado</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="admin.saveTestimonial()">
|
||||
<i class="bi bi-check-lg me-2"></i>Guardar testimonio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SERVICE MODAL ============ -->
|
||||
<div class="modal fade" id="serviceModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-briefcase me-2"></i>Añadir servicio</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Icono (clase Bootstrap)</label>
|
||||
<input type="text" class="form-control" name="icon" placeholder="bi bi-key">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Título (ES)</label>
|
||||
<input type="text" class="form-control" name="title_es" placeholder="Asesoría Legal">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Título (RU)</label>
|
||||
<input type="text" class="form-control" name="title_ru" placeholder="Юридическая консультация">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Descripción (ES)</label>
|
||||
<textarea class="form-control" rows="3" name="description_es" placeholder="Descripción del servicio..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Descripción (RU)</label>
|
||||
<textarea class="form-control" rows="3" name="description_ru" placeholder="Описание услуги..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Orden</label>
|
||||
<input type="number" class="form-control" name="order_num" placeholder="0">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="serviceActive" checked>
|
||||
<label class="form-check-label" for="serviceActive">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="admin.saveService()">
|
||||
<i class="bi bi-check-lg me-2"></i>Guardar servicio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ FAQ MODAL ============ -->
|
||||
<div class="modal fade" id="faqModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Añadir pregunta</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Pregunta (ES)</label>
|
||||
<input type="text" class="form-control" name="question_es" placeholder="¿Puedo comprar terreno siendo extranjero en España?">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Pregunta (RU)</label>
|
||||
<input type="text" class="form-control" name="question_ru" placeholder="Можно ли купить землю иностранцу в Испании?">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Respuesta (ES)</label>
|
||||
<textarea class="form-control" rows="3" name="answer_es" placeholder="Respuesta detallada..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Respuesta (RU)</label>
|
||||
<textarea class="form-control" rows="3" name="answer_ru" placeholder="Подробный ответ..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Categoría</label>
|
||||
<select class="form-select" name="category">
|
||||
<option value="general">General</option>
|
||||
<option value="legal">Legal</option>
|
||||
<option value="buying">Compra</option>
|
||||
<option value="taxes">Impuestos</option>
|
||||
<option value="property">Propiedad</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Orden</label>
|
||||
<input type="number" class="form-control" name="order_num" placeholder="0">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="faqActive" checked>
|
||||
<label class="form-check-label" for="faqActive">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="admin.saveFAQ()">
|
||||
<i class="bi bi-check-lg me-2"></i>Guardar pregunta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ LEAD MODAL ============ -->
|
||||
<div class="modal fade" id="leadModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Añadir lead</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nombre</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="Juan Pérez">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" name="email" placeholder="juan@ejemplo.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Teléfono</label>
|
||||
<input type="text" class="form-control" name="phone" placeholder="+34 600 123 456">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mensaje</label>
|
||||
<textarea class="form-control" rows="3" name="message" placeholder="Mensaje del lead..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Propiedad (ID)</label>
|
||||
<input type="text" class="form-control" name="property_id" placeholder="ID propiedad opcional">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Estado</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="new">Nuevo</option>
|
||||
<option value="contacted">Contactado</option>
|
||||
<option value="qualified">Calificado</option>
|
||||
<option value="negotiating">Negociando</option>
|
||||
<option value="closed">Cerrado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Fuente</label>
|
||||
<select class="form-select" name="source">
|
||||
<option value="webform">Formulario web</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="phone">Teléfono</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="admin.saveLead()">
|
||||
<i class="bi bi-check-lg me-2"></i>Guardar lead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<button class="btn btn-outline-primary">
|
||||
<i class="bi bi-download me-2"></i>Exportar
|
||||
</button>
|
||||
<button class="btn btn-primary">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#leadModal">
|
||||
<i class="bi bi-plus-lg me-2"></i>Añadir lead manualmente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -381,18 +381,45 @@ class AdminPanel {
|
||||
}
|
||||
|
||||
editLead(id) {
|
||||
const lead = this.leads.find(l => l.id === id)
|
||||
if (!lead) return
|
||||
const newStatus = prompt(`Cambiar estado del lead (${lead.name}):\n\nOpciones: new, contacted, qualified, negotiating, closed, lost`, lead.status)
|
||||
if (!newStatus || newStatus === lead.status) return
|
||||
API.updateLead(id, { status: newStatus }).then(res => {
|
||||
if (res.success) {
|
||||
this.showNotification('Lead actualizado', 'success')
|
||||
this.loadLeads()
|
||||
} else {
|
||||
this.showNotification(res.error || 'Error', 'error')
|
||||
}
|
||||
this.showLeadModal(this.leads.find(l => l.id === id))
|
||||
}
|
||||
|
||||
showLeadModal(lead = null) {
|
||||
const modal = document.getElementById('leadModal')
|
||||
if (!modal) return
|
||||
const title = modal.querySelector('.modal-title')
|
||||
const inputs = modal.querySelectorAll('input[name], textarea[name], select[name]')
|
||||
if (lead) {
|
||||
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar lead'
|
||||
inputs.forEach(i => { const k = i.getAttribute('name'); if (lead[k] !== undefined) i.value = lead[k] })
|
||||
modal._editId = lead.id
|
||||
} else {
|
||||
title.innerHTML = '<i class="bi bi-person-plus me-2"></i>Añadir lead'
|
||||
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
|
||||
modal.querySelector('select[name="status"]').value = 'new'
|
||||
modal.querySelector('select[name="source"]').value = 'manual'
|
||||
modal._editId = null
|
||||
}
|
||||
new bootstrap.Modal(modal).show()
|
||||
}
|
||||
|
||||
async saveLead() {
|
||||
const modal = document.getElementById('leadModal')
|
||||
if (!modal) return
|
||||
const data = {}
|
||||
modal.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
|
||||
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
|
||||
})
|
||||
if (!data.name || !data.name.trim()) { this.showNotification('El nombre es obligatorio', 'error'); return }
|
||||
const editId = modal._editId
|
||||
const res = editId ? await API.updateLead(editId, data) : await API.createLead(data)
|
||||
if (res.success) {
|
||||
bootstrap.Modal.getInstance(modal)?.hide()
|
||||
this.showNotification(editId ? 'Lead actualizado' : 'Lead creado', 'success')
|
||||
this.loadLeads()
|
||||
} else {
|
||||
this.showNotification(res.error || 'Error al guardar', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLead(id) {
|
||||
@@ -445,7 +472,51 @@ class AdminPanel {
|
||||
`).join('')
|
||||
}
|
||||
|
||||
showTestimonialModal(t = null) {
|
||||
const modal = document.getElementById('testimonialModal')
|
||||
if (!modal) return
|
||||
const title = modal.querySelector('.modal-title')
|
||||
const inputs = modal.querySelectorAll('input[name], textarea[name], select[name]')
|
||||
if (t) {
|
||||
title.innerHTML = '\u003ci class="bi bi-pencil me-2"\u003e\u003c/i\u003eEditar testimonio'
|
||||
inputs.forEach(i => {
|
||||
const k = i.getAttribute('name')
|
||||
if (k === 'is_approved') { i.checked = t.is_approved !== false; return }
|
||||
if (t[k] !== undefined) i.value = t[k]
|
||||
})
|
||||
modal._editId = t.id
|
||||
} else {
|
||||
title.innerHTML = '\u003ci class="bi bi-chat-quote me-2"\u003e\u003c/i\u003eAñadir testimonio'
|
||||
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
|
||||
modal._editId = null
|
||||
}
|
||||
new bootstrap.Modal(modal).show()
|
||||
}
|
||||
|
||||
async saveTestimonial() {
|
||||
const modal = document.getElementById('testimonialModal')
|
||||
if (!modal) return
|
||||
const data = {}
|
||||
modal.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
|
||||
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
|
||||
})
|
||||
data.rating = parseInt(data.rating) || 5
|
||||
const editId = modal._editId
|
||||
const res = editId ? await API.updateTestimonial(editId, data) : await API.createTestimonial(data)
|
||||
if (res.success) {
|
||||
bootstrap.Modal.getInstance(modal)?.hide()
|
||||
this.showNotification(editId ? 'Testimonio actualizado' : 'Testimonio creado', 'success')
|
||||
this.loadTestimonials()
|
||||
} else {
|
||||
this.showNotification(res.error || 'Error al guardar', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
editTestimonial(id) {
|
||||
this.showTestimonialModal(this.testimonials.find(x => x.id === id))
|
||||
}
|
||||
|
||||
async deleteTestimonial(id) {
|
||||
const t = this.testimonials.find(x => x.id === id)
|
||||
if (!t) return
|
||||
const newText = prompt('Editar texto del testimonio:', t.text_es || t.text)
|
||||
@@ -489,17 +560,47 @@ class AdminPanel {
|
||||
`).join('')
|
||||
}
|
||||
|
||||
editFAQ(id) {
|
||||
const f = this.faqs.find(x => x.id === id)
|
||||
if (!f) return
|
||||
const newQ = prompt('Pregunta (ES):', f.question_es || f.question)
|
||||
if (newQ === null) return
|
||||
const newA = prompt('Respuesta (ES):', f.answer_es || f.answer)
|
||||
if (newA === null) return
|
||||
API.updateFAQ(id, { question_es: newQ, answer_es: newA, question_ru: f.question_ru, answer_ru: f.answer_ru, category: f.category || 'general', order_num: f.order_num || 0, is_active: f.is_active !== false }).then(res => {
|
||||
if (res.success) { this.showNotification('FAQ actualizado', 'success'); this.loadFAQ() }
|
||||
else this.showNotification(res.error || 'Error', 'error')
|
||||
showFAQModal(f = null) {
|
||||
const modal = document.getElementById('faqModal')
|
||||
if (!modal) return
|
||||
const title = modal.querySelector('.modal-title')
|
||||
const inputs = modal.querySelectorAll('input[name], textarea[name], select[name]')
|
||||
if (f) {
|
||||
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar pregunta'
|
||||
inputs.forEach(i => { const k = i.getAttribute('name'); if (k === 'is_active') { i.checked = f.is_active !== false; return } if (f[k] !== undefined) i.value = f[k] })
|
||||
modal._editId = f.id
|
||||
} else {
|
||||
title.innerHTML = '<i class="bi bi-question-circle me-2"></i>Añadir pregunta'
|
||||
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
|
||||
modal.querySelector('select[name="category"]').value = 'general'
|
||||
modal._editId = null
|
||||
}
|
||||
new bootstrap.Modal(modal).show()
|
||||
}
|
||||
|
||||
async saveFAQ() {
|
||||
const modal = document.getElementById('faqModal')
|
||||
if (!modal) return
|
||||
const data = {}
|
||||
modal.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
|
||||
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
|
||||
})
|
||||
if (!data.question_es || !data.question_es.trim()) { this.showNotification('La pregunta es obligatoria', 'error'); return }
|
||||
if (!data.answer_es || !data.answer_es.trim()) { this.showNotification('La respuesta es obligatoria', 'error'); return }
|
||||
data.order_num = parseInt(data.order_num) || 0
|
||||
const editId = modal._editId
|
||||
const res = editId ? await API.updateFAQ(editId, data) : await API.createFAQ(data)
|
||||
if (res.success) {
|
||||
bootstrap.Modal.getInstance(modal)?.hide()
|
||||
this.showNotification(editId ? 'FAQ actualizado' : 'FAQ creado', 'success')
|
||||
this.loadFAQ()
|
||||
} else {
|
||||
this.showNotification(res.error || 'Error al guardar', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
editFAQ(id) {
|
||||
this.showFAQModal(this.faqs.find(x => x.id === id))
|
||||
}
|
||||
|
||||
async deleteFAQ(id) {
|
||||
@@ -536,6 +637,43 @@ class AdminPanel {
|
||||
`).join('')
|
||||
}
|
||||
|
||||
showServiceModal(s = null) {
|
||||
const modal = document.getElementById('serviceModal')
|
||||
if (!modal) return
|
||||
const title = modal.querySelector('.modal-title')
|
||||
const inputs = modal.querySelectorAll('input[name], textarea[name]')
|
||||
if (s) {
|
||||
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar servicio'
|
||||
inputs.forEach(i => { const k = i.getAttribute('name'); if (k === 'is_active') { i.checked = s.is_active !== false; return } if (s[k] !== undefined) i.value = s[k] })
|
||||
modal._editId = s.id
|
||||
} else {
|
||||
title.innerHTML = '<i class="bi bi-briefcase me-2"></i>Añadir servicio'
|
||||
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
|
||||
modal._editId = null
|
||||
}
|
||||
new bootstrap.Modal(modal).show()
|
||||
}
|
||||
|
||||
async saveService() {
|
||||
const modal = document.getElementById('serviceModal')
|
||||
if (!modal) return
|
||||
const data = {}
|
||||
modal.querySelectorAll('input[name], textarea[name]').forEach(i => {
|
||||
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
|
||||
})
|
||||
if (!data.title_es || !data.title_es.trim()) { this.showNotification('El título es obligatorio', 'error'); return }
|
||||
data.order_num = parseInt(data.order_num) || 0
|
||||
const editId = modal._editId
|
||||
const res = editId ? await API.updateService(editId, data) : await API.createService(data)
|
||||
if (res.success) {
|
||||
bootstrap.Modal.getInstance(modal)?.hide()
|
||||
this.showNotification(editId ? 'Servicio actualizado' : 'Servicio creado', 'success')
|
||||
this.loadServices()
|
||||
} else {
|
||||
this.showNotification(res.error || 'Error al guardar', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
editService(id) {
|
||||
const s = this.services.find(x => x.id === id)
|
||||
if (!s) return
|
||||
|
||||
115
public/js/admin/modals/FAQModal.js
Normal file
115
public/js/admin/modals/FAQModal.js
Normal file
@@ -0,0 +1,115 @@
|
||||
export class FAQModal {
|
||||
constructor(app) {
|
||||
this.app = app
|
||||
this.id = 'faqModal'
|
||||
this.element = null
|
||||
this.bsModal = null
|
||||
this._editId = null
|
||||
}
|
||||
|
||||
open(faq = null) {
|
||||
if (!this.element) this._build()
|
||||
this._editId = faq ? faq.id : null
|
||||
const title = this.element.querySelector('.modal-title')
|
||||
const inputs = this.element.querySelectorAll('input[name], textarea[name], select[name]')
|
||||
if (faq) {
|
||||
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar pregunta'
|
||||
inputs.forEach(i => {
|
||||
const key = i.getAttribute('name')
|
||||
if (faq[key] !== undefined) i.value = faq[key]
|
||||
if (i.type === 'checkbox') i.checked = faq[key] !== false
|
||||
})
|
||||
} else {
|
||||
title.innerHTML = '<i class="bi bi-question-circle me-2"></i>Añadir pregunta'
|
||||
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
|
||||
this.element.querySelector('select[name="category"]').value = 'general'
|
||||
this.element.querySelector('input[name="order_num"]').value = ''
|
||||
}
|
||||
this.bsModal = new bootstrap.Modal(this.element)
|
||||
this.bsModal.show()
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.bsModal) this.bsModal.hide()
|
||||
}
|
||||
|
||||
async save() {
|
||||
const data = this._collect()
|
||||
if (!data.question_es || !data.question_es.trim()) {
|
||||
this.app.showNotification('La pregunta es obligatoria', 'error')
|
||||
return
|
||||
}
|
||||
if (!data.answer_es || !data.answer_es.trim()) {
|
||||
this.app.showNotification('La respuesta es obligatoria', 'error')
|
||||
return
|
||||
}
|
||||
const res = this._editId
|
||||
? await API.updateFAQ(this._editId, data)
|
||||
: await API.createFAQ(data)
|
||||
if (res.success) {
|
||||
this.close()
|
||||
this.app.showNotification(this._editId ? 'FAQ actualizado' : 'FAQ creado', 'success')
|
||||
this.app.refreshSection('faq')
|
||||
} else {
|
||||
this.app.showNotification(res.error || 'Error al guardar', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
_collect() {
|
||||
const d = {}
|
||||
this.element.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
|
||||
const k = i.getAttribute('name')
|
||||
d[k] = i.type === 'checkbox' ? i.checked : i.value
|
||||
})
|
||||
d.order_num = parseInt(d.order_num) || 0
|
||||
d.is_active = d.is_active !== undefined ? d.is_active : true
|
||||
return d
|
||||
}
|
||||
|
||||
_build() {
|
||||
const el = document.createElement('div')
|
||||
el.id = this.id
|
||||
el.className = 'modal fade'
|
||||
el.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Añadir pregunta</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3"><label class="form-label">Pregunta (ES)</label>
|
||||
<input type="text" class="form-control" name="question_es" placeholder="¿Puedo comprar terreno...?"></div>
|
||||
<div class="mb-3"><label class="form-label">Pregunta (RU)</label>
|
||||
<input type="text" class="form-control" name="question_ru" placeholder="Могу ли я купить землю...?"></div>
|
||||
<div class="mb-3"><label class="form-label">Respuesta (ES)</label>
|
||||
<textarea class="form-control" rows="3" name="answer_es" placeholder="Respuesta detallada..."></textarea></div>
|
||||
<div class="mb-3"><label class="form-label">Respuesta (RU)</label>
|
||||
<textarea class="form-control" rows="3" name="answer_ru" placeholder="Подробный ответ..."></textarea></div>
|
||||
<div class="mb-3"><label class="form-label">Categoría</label>
|
||||
<select class="form-select" name="category">
|
||||
<option value="general">General</option>
|
||||
<option value="legal">Legal</option>
|
||||
<option value="buying">Compra</option>
|
||||
<option value="taxes">Impuestos</option>
|
||||
<option value="property">Propiedad</option>
|
||||
</select></div>
|
||||
<div class="mb-3"><label class="form-label">Orden</label>
|
||||
<input type="number" class="form-control" name="order_num" placeholder="0"></div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="faqActive" checked>
|
||||
<label class="form-check-label" for="faqActive">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" id="faqSaveBtn">
|
||||
<i class="bi bi-check-lg me-2"></i>Guardar pregunta</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
document.body.appendChild(el)
|
||||
el.querySelector('#faqSaveBtn').addEventListener('click', () => this.save())
|
||||
this.element = el
|
||||
}
|
||||
}
|
||||
57
public/js/admin/modals/LeadModal.js
Normal file
57
public/js/admin/modals/LeadModal.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { BaseModal } from '../core/BaseModal.js';
|
||||
import { API } from '../../api.js';
|
||||
|
||||
export class LeadModal extends BaseModal {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'leadModal',
|
||||
title: 'Lead',
|
||||
icon: 'bi-person-plus',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Nombre', type: 'text', required: true },
|
||||
{ name: 'email', label: 'Email', type: 'email' },
|
||||
{ name: 'phone', label: 'Teléfono', type: 'text' },
|
||||
{ name: 'message', label: 'Mensaje', type: 'textarea', rows: 3 },
|
||||
{ name: 'property_id', label: 'ID propiedad', type: 'text', placeholder: 'ID propiedad opcional' },
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Estado',
|
||||
type: 'select',
|
||||
defaultValue: 'new',
|
||||
options: [
|
||||
{ value: 'new', label: 'Nuevo' },
|
||||
{ value: 'contacted', label: 'Contactado' },
|
||||
{ value: 'qualified', label: 'Cualificado' },
|
||||
{ value: 'negotiating', label: 'Negociando' },
|
||||
{ value: 'closed', label: 'Cerrado' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
label: 'Fuente',
|
||||
type: 'select',
|
||||
defaultValue: 'manual',
|
||||
options: [
|
||||
{ value: 'webform', label: 'Formulario web' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Teléfono' },
|
||||
{ value: 'manual', label: 'Manual' }
|
||||
]
|
||||
}
|
||||
],
|
||||
onSave: async (data, editId) => {
|
||||
// For leads, we always use the admin API endpoints
|
||||
const res = editId
|
||||
? await API.updateLead(editId, data)
|
||||
: await API.createLead(data);
|
||||
if (res.success) {
|
||||
window.app.showNotification(editId ? 'Lead actualizado' : 'Lead creado', 'success');
|
||||
window.app.refreshSection('leads');
|
||||
} else {
|
||||
throw new Error(res.error || 'Error al guardar lead');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
102
public/js/admin/modals/ServiceModal.js
Normal file
102
public/js/admin/modals/ServiceModal.js
Normal file
@@ -0,0 +1,102 @@
|
||||
export class ServiceModal {
|
||||
constructor(app) {
|
||||
this.app = app
|
||||
this.id = 'serviceModal'
|
||||
this.element = null
|
||||
this.bsModal = null
|
||||
this._editId = null
|
||||
}
|
||||
|
||||
open(service = null) {
|
||||
if (!this.element) this._build()
|
||||
this._editId = service ? service.id : null
|
||||
const title = this.element.querySelector('.modal-title')
|
||||
const inputs = this.element.querySelectorAll('input[name], textarea[name]')
|
||||
if (service) {
|
||||
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar servicio'
|
||||
inputs.forEach(i => {
|
||||
const key = i.getAttribute('name')
|
||||
if (service[key] !== undefined) i.value = service[key]
|
||||
if (i.type === 'checkbox') i.checked = service[key] !== false
|
||||
})
|
||||
} else {
|
||||
title.innerHTML = '<i class="bi bi-briefcase me-2"></i>Añadir servicio'
|
||||
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
|
||||
}
|
||||
this.bsModal = new bootstrap.Modal(this.element)
|
||||
this.bsModal.show()
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.bsModal) this.bsModal.hide()
|
||||
}
|
||||
|
||||
async save() {
|
||||
const data = this._collect()
|
||||
if (!data.title_es || !data.title_es.trim()) {
|
||||
this.app.showNotification('Título es obligatorio', 'error')
|
||||
return
|
||||
}
|
||||
const res = this._editId
|
||||
? await API.updateService(this._editId, data)
|
||||
: await API.createService(data)
|
||||
if (res.success) {
|
||||
this.close()
|
||||
this.app.showNotification(this._editId ? 'Servicio actualizado' : 'Servicio creado', 'success')
|
||||
this.app.refreshSection('services')
|
||||
} else {
|
||||
this.app.showNotification(res.error || 'Error al guardar', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
_collect() {
|
||||
const d = {}
|
||||
this.element.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
|
||||
const k = i.getAttribute('name')
|
||||
d[k] = i.type === 'checkbox' ? i.checked : i.value
|
||||
})
|
||||
d.order_num = parseInt(d.order_num) || 0
|
||||
d.is_active = d.is_active !== undefined ? d.is_active : true
|
||||
return d
|
||||
}
|
||||
|
||||
_build() {
|
||||
const el = document.createElement('div')
|
||||
el.id = this.id
|
||||
el.className = 'modal fade'
|
||||
el.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-briefcase me-2"></i>Añadir servicio</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3"><label class="form-label">Icono (clase Bootstrap)</label>
|
||||
<input type="text" class="form-control" name="icon" placeholder="bi bi-key"></div>
|
||||
<div class="mb-3"><label class="form-label">Título (ES)</label>
|
||||
<input type="text" class="form-control" name="title_es" placeholder="Asesoría Legal"></div>
|
||||
<div class="mb-3"><label class="form-label">Título (RU)</label>
|
||||
<input type="text" class="form-control" name="title_ru" placeholder="Юридическая консультация"></div>
|
||||
<div class="mb-3"><label class="form-label">Descripción (ES)</label>
|
||||
<textarea class="form-control" rows="3" name="description_es" placeholder="Descripción del servicio..."></textarea></div>
|
||||
<div class="mb-3"><label class="form-label">Descripción (RU)</label>
|
||||
<textarea class="form-control" rows="3" name="description_ru" placeholder="Описание услуги..."></textarea></div>
|
||||
<div class="mb-3"><label class="form-label">Orden</label>
|
||||
<input type="number" class="form-control" name="order_num" placeholder="0"></div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="serviceActive" checked>
|
||||
<label class="form-check-label" for="serviceActive">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="window.app.modals.service.save()">
|
||||
<i class="bi bi-check-lg me-2"></i>Guardar servicio</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
document.body.appendChild(el)
|
||||
this.element = el
|
||||
}
|
||||
}
|
||||
205
tests/admin/api-client.test.js
Normal file
205
tests/admin/api-client.test.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'bun:test'
|
||||
import { API } from '../../public/js/api.js'
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock Response object for fetch
|
||||
global.Response = class Response {
|
||||
constructor(data) {
|
||||
this.data = data
|
||||
}
|
||||
async json() {
|
||||
return this.data
|
||||
}
|
||||
}
|
||||
|
||||
describe('API Client Methods', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Testimonial Methods', () => {
|
||||
it('should have createTestimonial method', () => {
|
||||
expect(typeof API.createTestimonial).toBe('function')
|
||||
})
|
||||
|
||||
it('should have updateTestimonial method', () => {
|
||||
expect(typeof API.updateTestimonial).toBe('function')
|
||||
})
|
||||
|
||||
it('createTestimonial should make POST request to correct endpoint', async () => {
|
||||
const mockData = { name: 'John', text: 'Great service' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.createTestimonial(mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/admin/testimonials', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockData)
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('updateTestimonial should make PUT request to correct endpoint', async () => {
|
||||
const mockData = { name: 'John', text: 'Updated testimonial' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.updateTestimonial(1, mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/admin/testimonials/1', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockData)
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Service Methods', () => {
|
||||
it('should have createService method', () => {
|
||||
expect(typeof API.createService).toBe('function')
|
||||
})
|
||||
|
||||
it('should have updateService method', () => {
|
||||
expect(typeof API.updateService).toBe('function')
|
||||
})
|
||||
|
||||
it('createService should make POST request to correct endpoint', async () => {
|
||||
const mockData = { title: 'New Service', description: 'Service description' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.createService(mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/admin/services', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockData)
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('updateService should make PUT request to correct endpoint', async () => {
|
||||
const mockData = { title: 'Updated Service', description: 'Updated description' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.updateService(1, mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/admin/services/1', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockData)
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FAQ Methods', () => {
|
||||
it('should have createFAQ method', () => {
|
||||
expect(typeof API.createFAQ).toBe('function')
|
||||
})
|
||||
|
||||
it('should have updateFAQ method', () => {
|
||||
expect(typeof API.updateFAQ).toBe('function')
|
||||
})
|
||||
|
||||
it('createFAQ should make POST request to correct endpoint', async () => {
|
||||
const mockData = { question: 'FAQ Question', answer: 'FAQ Answer' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.createFAQ(mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/admin/faq', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockData)
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('updateFAQ should make PUT request to correct endpoint', async () => {
|
||||
const mockData = { question: 'Updated FAQ Question', answer: 'Updated FAQ Answer' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.updateFAQ(1, mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/admin/faq/1', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockData)
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lead Methods', () => {
|
||||
it('should have createLead method', () => {
|
||||
expect(typeof API.createLead).toBe('function')
|
||||
})
|
||||
|
||||
it('should have updateLead method', () => {
|
||||
expect(typeof API.updateLead).toBe('function')
|
||||
})
|
||||
|
||||
it('createLead should make POST request to correct endpoint', async () => {
|
||||
const mockData = { name: 'John Doe', email: 'john@example.com', message: 'Hello' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.createLead(mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/leads', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: mockData.name,
|
||||
email: mockData.email,
|
||||
phone: '',
|
||||
message: mockData.message,
|
||||
property_id: undefined,
|
||||
language: undefined
|
||||
})
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('updateLead should make PUT request to correct endpoint', async () => {
|
||||
const mockData = { name: 'John Doe', status: 'contacted' }
|
||||
const mockResponse = { success: true, data: { id: 1, ...mockData } }
|
||||
|
||||
fetch.mockResolvedValueOnce(new Response(mockResponse))
|
||||
|
||||
const result = await API.updateLead(1, mockData)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/admin/leads/1', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockData)
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
175
tests/admin/base-modal.test.js
Normal file
175
tests/admin/base-modal.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'bun:test'
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
// Set up DOM environment
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>')
|
||||
global.document = dom.window.document
|
||||
global.window = dom.window
|
||||
global.bootstrap = {
|
||||
Modal: vi.fn().mockImplementation(() => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn()
|
||||
}))
|
||||
}
|
||||
|
||||
// Since BaseModal doesn't exist yet, we're writing tests that will fail (RED phase)
|
||||
describe('BaseModal', () => {
|
||||
let BaseModal
|
||||
|
||||
// Try to import BaseModal, but it doesn't exist yet
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const module = await import('../../public/js/admin/modals/BaseModal.js')
|
||||
BaseModal = module.BaseModal
|
||||
} catch (e) {
|
||||
// Expected since BaseModal doesn't exist yet
|
||||
BaseModal = null
|
||||
}
|
||||
})
|
||||
|
||||
it('should be importable', () => {
|
||||
// This test will fail because BaseModal doesn't exist yet
|
||||
expect(BaseModal).not.toBeNull()
|
||||
})
|
||||
|
||||
it('_buildDOM should create correct modal structure', () => {
|
||||
// This test will fail because BaseModal doesn't exist yet
|
||||
const config = {
|
||||
id: 'testModal',
|
||||
title: 'Test Modal',
|
||||
fields: []
|
||||
}
|
||||
|
||||
const modal = new BaseModal(config)
|
||||
expect(modal.element).toBeDefined()
|
||||
expect(modal.element.id).toBe('testModal')
|
||||
expect(modal.element.querySelector('.modal-title').textContent).toBe('Test Modal')
|
||||
})
|
||||
|
||||
it('collectData should gather data from form fields', () => {
|
||||
// This test will fail because BaseModal doesn't exist yet
|
||||
const config = {
|
||||
id: 'testModal',
|
||||
title: 'Test Modal',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'email', label: 'Email', type: 'email' }
|
||||
]
|
||||
}
|
||||
|
||||
const modal = new BaseModal(config)
|
||||
|
||||
// Mock form elements
|
||||
modal.element.querySelector = vi.fn().mockImplementation(selector => {
|
||||
const mockElements = {
|
||||
'[name="name"]': { value: 'John Doe' },
|
||||
'[name="email"]': { value: 'john@example.com' }
|
||||
}
|
||||
return mockElements[selector]
|
||||
})
|
||||
|
||||
const data = modal.collectData()
|
||||
expect(data).toEqual({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('validate should check required fields', () => {
|
||||
// This test will fail because BaseModal doesn't exist yet
|
||||
const config = {
|
||||
id: 'testModal',
|
||||
title: 'Test Modal',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'email', label: 'Email', type: 'email' }
|
||||
]
|
||||
}
|
||||
|
||||
const modal = new BaseModal(config)
|
||||
|
||||
// Mock form elements
|
||||
modal.element.querySelector = vi.fn().mockImplementation(selector => {
|
||||
const mockElements = {
|
||||
'[name="name"]': { value: '', classList: { add: vi.fn(), remove: vi.fn() } }
|
||||
}
|
||||
return mockElements[selector]
|
||||
})
|
||||
|
||||
const result = modal.validate()
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0].field).toBe('name')
|
||||
})
|
||||
|
||||
it('open should populate form with data when provided', () => {
|
||||
// This test will fail because BaseModal doesn't exist yet
|
||||
const config = {
|
||||
id: 'testModal',
|
||||
title: 'Test Modal',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text' }
|
||||
]
|
||||
}
|
||||
|
||||
const modal = new BaseModal(config)
|
||||
const testData = { id: 1, name: 'John Doe' }
|
||||
|
||||
// Mock _fillForm and bootstrap.Modal
|
||||
modal._fillForm = vi.fn()
|
||||
const mockBootstrapModal = { show: vi.fn() }
|
||||
bootstrap.Modal.mockImplementation(() => mockBootstrapModal)
|
||||
|
||||
modal.open(testData)
|
||||
|
||||
expect(modal._editId).toBe(1)
|
||||
expect(modal._fillForm).toHaveBeenCalledWith(testData)
|
||||
expect(mockBootstrapModal.show).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('open should clear form when no data provided', () => {
|
||||
// This test will fail because BaseModal doesn't exist yet
|
||||
const config = {
|
||||
id: 'testModal',
|
||||
title: 'Test Modal',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text' }
|
||||
]
|
||||
}
|
||||
|
||||
const modal = new BaseModal(config)
|
||||
|
||||
// Mock _clearForm and bootstrap.Modal
|
||||
modal._clearForm = vi.fn()
|
||||
const mockBootstrapModal = { show: vi.fn() }
|
||||
bootstrap.Modal.mockImplementation(() => mockBootstrapModal)
|
||||
|
||||
modal.open()
|
||||
|
||||
expect(modal._editId).toBeNull()
|
||||
expect(modal._clearForm).toHaveBeenCalled()
|
||||
expect(mockBootstrapModal.show).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('close should hide the modal', () => {
|
||||
// This test will fail because BaseModal doesn't exist yet
|
||||
const config = {
|
||||
id: 'testModal',
|
||||
title: 'Test Modal',
|
||||
fields: []
|
||||
}
|
||||
|
||||
const modal = new BaseModal(config)
|
||||
|
||||
// Mock bootstrap.Modal.getInstance
|
||||
const mockHide = vi.fn()
|
||||
global.bootstrap.Modal.getInstance = vi.fn().mockReturnValue({
|
||||
hide: mockHide
|
||||
})
|
||||
|
||||
modal.close()
|
||||
|
||||
expect(global.bootstrap.Modal.getInstance).toHaveBeenCalledWith(modal.element)
|
||||
expect(mockHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
331
tests/admin/modals-e2e.test.js
Normal file
331
tests/admin/modals-e2e.test.js
Normal file
@@ -0,0 +1,331 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'bun:test'
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
// Set up DOM environment
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>')
|
||||
global.document = dom.window.document
|
||||
global.window = dom.window
|
||||
global.bootstrap = {
|
||||
Modal: vi.fn().mockImplementation(() => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn()
|
||||
}))
|
||||
}
|
||||
|
||||
// Mock API and App
|
||||
global.API = {
|
||||
createTestimonial: vi.fn(),
|
||||
updateTestimonial: vi.fn(),
|
||||
createService: vi.fn(),
|
||||
updateService: vi.fn(),
|
||||
createFAQ: vi.fn(),
|
||||
updateFAQ: vi.fn(),
|
||||
createLead: vi.fn(),
|
||||
updateLead: vi.fn()
|
||||
}
|
||||
|
||||
global.app = {
|
||||
showNotification: vi.fn(),
|
||||
refreshSection: vi.fn()
|
||||
}
|
||||
|
||||
describe('Admin Modals E2E Tests', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Clear DOM
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
// Since the modal classes don't exist yet, we're writing tests that will fail (RED phase)
|
||||
describe('TestimonialModal', () => {
|
||||
let TestimonialModal
|
||||
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const module = await import('../../public/js/admin/modals/TestimonialModal.js')
|
||||
TestimonialModal = module.TestimonialModal
|
||||
} catch (e) {
|
||||
// Expected since TestimonialModal doesn't exist yet
|
||||
TestimonialModal = null
|
||||
}
|
||||
})
|
||||
|
||||
it('should open when "Añadir" button is clicked', () => {
|
||||
// This test will fail because TestimonialModal doesn't exist yet
|
||||
expect(TestimonialModal).not.toBeNull()
|
||||
|
||||
// Create section HTML with add button
|
||||
document.body.innerHTML = `
|
||||
<section id="section-testimonials">
|
||||
<button id="addTestimonialBtn">Añadir testimonio</button>
|
||||
<div id="testimonialsTableBody"></div>
|
||||
</section>
|
||||
`
|
||||
|
||||
// Create modal with mock onSave
|
||||
const modal = new TestimonialModal(vi.fn())
|
||||
|
||||
// Simulate button click
|
||||
const addButton = document.getElementById('addTestimonialBtn')
|
||||
addButton.onclick = () => modal.open()
|
||||
addButton.click()
|
||||
|
||||
// Verify modal opened
|
||||
expect(modal.open).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should contain correct fields', () => {
|
||||
// This test will fail because TestimonialModal doesn't exist yet
|
||||
const modal = new TestimonialModal(vi.fn())
|
||||
|
||||
// Check that fields exist
|
||||
const fields = modal.config.fields
|
||||
expect(fields).toContainEqual(expect.objectContaining({ name: 'name', label: 'Nombre' }))
|
||||
expect(fields).toContainEqual(expect.objectContaining({ name: 'location', label: 'Ubicación' }))
|
||||
expect(fields).toContainEqual(expect.objectContaining({ name: 'rating', label: 'Valoración' }))
|
||||
expect(fields).toContainEqual(expect.objectContaining({ name: 'text_es', label: 'Texto (ES)' }))
|
||||
expect(fields).toContainEqual(expect.objectContaining({ name: 'text_ru', label: 'Texto (RU)' }))
|
||||
expect(fields).toContainEqual(expect.objectContaining({ name: 'is_approved', label: 'Aprobado' }))
|
||||
})
|
||||
|
||||
it('should call API on submit', async () => {
|
||||
// This test will fail because TestimonialModal doesn't exist yet
|
||||
const onSave = vi.fn()
|
||||
const modal = new TestimonialModal(onSave)
|
||||
|
||||
// Mock form data
|
||||
modal.collectData = vi.fn().mockReturnValue({
|
||||
name: 'John Doe',
|
||||
text_es: 'Great service',
|
||||
is_approved: true
|
||||
})
|
||||
|
||||
modal.validate = vi.fn().mockReturnValue({ valid: true, errors: [] })
|
||||
|
||||
// Mock API response
|
||||
global.API.createTestimonial.mockResolvedValueOnce({ success: true })
|
||||
|
||||
// Trigger save
|
||||
await modal._onSave()
|
||||
|
||||
// Verify API call
|
||||
expect(global.API.createTestimonial).toHaveBeenCalledWith({
|
||||
name: 'John Doe',
|
||||
text_es: 'Great service',
|
||||
is_approved: true
|
||||
})
|
||||
|
||||
// Verify notification and section refresh
|
||||
expect(global.app.showNotification).toHaveBeenCalledWith('Creado', 'success')
|
||||
expect(global.app.refreshSection).toHaveBeenCalledWith('testimonials')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ServiceModal', () => {
|
||||
let ServiceModal
|
||||
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const module = await import('../../public/js/admin/modals/ServiceModal.js')
|
||||
ServiceModal = module.ServiceModal
|
||||
} catch (e) {
|
||||
// Expected since ServiceModal doesn't exist yet
|
||||
ServiceModal = null
|
||||
}
|
||||
})
|
||||
|
||||
it('should open when "Añadir" button is clicked', () => {
|
||||
// This test will fail because ServiceModal doesn't exist yet
|
||||
expect(ServiceModal).not.toBeNull()
|
||||
|
||||
// Create section HTML with add button
|
||||
document.body.innerHTML = `
|
||||
<section id="section-services">
|
||||
<button id="addServiceBtn">Añadir servicio</button>
|
||||
<div id="servicesTableBody"></div>
|
||||
</section>
|
||||
`
|
||||
|
||||
// Create modal with mock onSave
|
||||
const modal = new ServiceModal(vi.fn())
|
||||
|
||||
// Simulate button click
|
||||
const addButton = document.getElementById('addServiceBtn')
|
||||
addButton.onclick = () => modal.open()
|
||||
addButton.click()
|
||||
|
||||
// Verify modal opened
|
||||
expect(modal.open).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call API on submit', async () => {
|
||||
// This test will fail because ServiceModal doesn't exist yet
|
||||
const onSave = vi.fn()
|
||||
const modal = new ServiceModal(onSave)
|
||||
|
||||
// Mock form data
|
||||
modal.collectData = vi.fn().mockReturnValue({
|
||||
title_es: 'New Service',
|
||||
description_es: 'Service description'
|
||||
})
|
||||
|
||||
modal.validate = vi.fn().mockReturnValue({ valid: true, errors: [] })
|
||||
|
||||
// Mock API response
|
||||
global.API.createService.mockResolvedValueOnce({ success: true })
|
||||
|
||||
// Trigger save
|
||||
await modal._onSave()
|
||||
|
||||
// Verify API call
|
||||
expect(global.API.createService).toHaveBeenCalledWith({
|
||||
title_es: 'New Service',
|
||||
description_es: 'Service description'
|
||||
})
|
||||
|
||||
// Verify notification and section refresh
|
||||
expect(global.app.showNotification).toHaveBeenCalledWith('Creado', 'success')
|
||||
expect(global.app.refreshSection).toHaveBeenCalledWith('services')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FAQModal', () => {
|
||||
let FAQModal
|
||||
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const module = await import('../../public/js/admin/modals/FAQModal.js')
|
||||
FAQModal = module.FAQModal
|
||||
} catch (e) {
|
||||
// Expected since FAQModal doesn't exist yet
|
||||
FAQModal = null
|
||||
}
|
||||
})
|
||||
|
||||
it('should open when "Añadir" button is clicked', () => {
|
||||
// This test will fail because FAQModal doesn't exist yet
|
||||
expect(FAQModal).not.toBeNull()
|
||||
|
||||
// Create section HTML with add button
|
||||
document.body.innerHTML = `
|
||||
<section id="section-faq">
|
||||
<button id="addFAQBtn">Añadir FAQ</button>
|
||||
<div id="faqTableBody"></div>
|
||||
</section>
|
||||
`
|
||||
|
||||
// Create modal with mock onSave
|
||||
const modal = new FAQModal(vi.fn())
|
||||
|
||||
// Simulate button click
|
||||
const addButton = document.getElementById('addFAQBtn')
|
||||
addButton.onclick = () => modal.open()
|
||||
addButton.click()
|
||||
|
||||
// Verify modal opened
|
||||
expect(modal.open).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call API on submit', async () => {
|
||||
// This test will fail because FAQModal doesn't exist yet
|
||||
const onSave = vi.fn()
|
||||
const modal = new FAQModal(onSave)
|
||||
|
||||
// Mock form data
|
||||
modal.collectData = vi.fn().mockReturnValue({
|
||||
question_es: 'FAQ Question',
|
||||
answer_es: 'FAQ Answer'
|
||||
})
|
||||
|
||||
modal.validate = vi.fn().mockReturnValue({ valid: true, errors: [] })
|
||||
|
||||
// Mock API response
|
||||
global.API.createFAQ.mockResolvedValueOnce({ success: true })
|
||||
|
||||
// Trigger save
|
||||
await modal._onSave()
|
||||
|
||||
// Verify API call
|
||||
expect(global.API.createFAQ).toHaveBeenCalledWith({
|
||||
question_es: 'FAQ Question',
|
||||
answer_es: 'FAQ Answer'
|
||||
})
|
||||
|
||||
// Verify notification and section refresh
|
||||
expect(global.app.showNotification).toHaveBeenCalledWith('Creado', 'success')
|
||||
expect(global.app.refreshSection).toHaveBeenCalledWith('faq')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LeadModal', () => {
|
||||
let LeadModal
|
||||
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const module = await import('../../public/js/admin/modals/LeadModal.js')
|
||||
LeadModal = module.LeadModal
|
||||
} catch (e) {
|
||||
// Expected since LeadModal doesn't exist yet
|
||||
LeadModal = null
|
||||
}
|
||||
})
|
||||
|
||||
it('should open when "Añadir" button is clicked', () => {
|
||||
// This test will fail because LeadModal doesn't exist yet
|
||||
expect(LeadModal).not.toBeNull()
|
||||
|
||||
// Create section HTML with add button
|
||||
document.body.innerHTML = `
|
||||
<section id="section-leads">
|
||||
<button id="addLeadBtn">Añadir lead</button>
|
||||
<div id="leadsTableBody"></div>
|
||||
</section>
|
||||
`
|
||||
|
||||
// Create modal with mock onSave
|
||||
const modal = new LeadModal(vi.fn())
|
||||
|
||||
// Simulate button click
|
||||
const addButton = document.getElementById('addLeadBtn')
|
||||
addButton.onclick = () => modal.open()
|
||||
addButton.click()
|
||||
|
||||
// Verify modal opened
|
||||
expect(modal.open).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call API on submit', async () => {
|
||||
// This test will fail because LeadModal doesn't exist yet
|
||||
const onSave = vi.fn()
|
||||
const modal = new LeadModal(onSave)
|
||||
|
||||
// Mock form data
|
||||
modal.collectData = vi.fn().mockReturnValue({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
message: 'Hello'
|
||||
})
|
||||
|
||||
modal.validate = vi.fn().mockReturnValue({ valid: true, errors: [] })
|
||||
|
||||
// Mock API response
|
||||
global.API.createLead.mockResolvedValueOnce({ success: true })
|
||||
|
||||
// Trigger save
|
||||
await modal._onSave()
|
||||
|
||||
// Verify API call
|
||||
expect(global.API.createLead).toHaveBeenCalledWith({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
message: 'Hello'
|
||||
})
|
||||
|
||||
// Verify notification and section refresh
|
||||
expect(global.app.showNotification).toHaveBeenCalledWith('Creado', 'success')
|
||||
expect(global.app.refreshSection).toHaveBeenCalledWith('leads')
|
||||
})
|
||||
})
|
||||
})
|
||||
221
tests/scripts/admin-panel-deep-test.js
Normal file
221
tests/scripts/admin-panel-deep-test.js
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
/// Admin Panel Deep Functional Test v6 — navigates via window.admin.navigateTo()
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TARGET_URL = process.env.TARGET_URL || 'http://localhost:3003';
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@tenerifeprop.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin@2026!';
|
||||
const EXE_PATH = process.env.PLAYWRIGHT_EXECUTABLE_PATH;
|
||||
const REPORT_DIR = path.join(__dirname, '../reports');
|
||||
const SCREENSHOT_DIR = path.join(__dirname, '../visual/admin');
|
||||
|
||||
const SECTIONS = [
|
||||
{ name: 'dashboard', label: 'Dashboard', expected_modals: 0 },
|
||||
{ name: 'properties', label: 'Propiedades', expected_modals: 1 },
|
||||
{ name: 'leads', label: 'Leads', expected_modals: 0 },
|
||||
{ name: 'testimonials', label: 'Testimonios', expected_modals: 1 },
|
||||
{ name: 'services', label: 'Servicios', expected_modals: 1 },
|
||||
{ name: 'faq', label: 'FAQ', expected_modals: 1 },
|
||||
{ name: 'users', label: 'Usuarios', expected_modals: 1 },
|
||||
{ name: 'settings', label: 'Configuracion', expected_modals: 0 },
|
||||
{ name: 'analytics', label: 'Analytics', expected_modals: 0 },
|
||||
{ name: 'traffic', label: 'Trafico', expected_modals: 0 },
|
||||
];
|
||||
|
||||
let results = { timestamp: new Date().toISOString(), targetUrl: TARGET_URL, summary: { passed:0, failed:0, warnings:0 }, sections: [] };
|
||||
|
||||
function ensure() {
|
||||
[REPORT_DIR, SCREENSHOT_DIR].forEach(d => { if(!fs.existsSync(d)) fs.mkdirSync(d,{recursive:true}); });
|
||||
}
|
||||
|
||||
function includesAny(text, words) {
|
||||
const t = (text||'').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
return words.some(w => t.includes(w));
|
||||
}
|
||||
|
||||
async function navigateToSection(page, name) {
|
||||
await page.evaluate((n) => { if (window.admin) window.admin.navigateTo(n); }, name);
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
async function testSection(page, sec) {
|
||||
const res = { name:sec.name, label:sec.label, status:'ok', errors:[], buttons:[], actions:[], tables:[], modals:[], verdict:'pass' };
|
||||
console.log(`\n📄 ${sec.label}`);
|
||||
|
||||
const consoleMsgs=[];
|
||||
const onConsole = msg => { if(msg.type()==='error'||/error|uncaught|typeerror|referenceerror|cannot read|failed to fetch|networkerror/i.test(msg.text())) consoleMsgs.push(msg.text()); };
|
||||
page.on('console', onConsole);
|
||||
|
||||
try {
|
||||
await navigateToSection(page, sec.name);
|
||||
res.status='loaded';
|
||||
} catch(e) { res.status='nav-failed'; res.errors.push(e.message); res.verdict='fail'; page.off('console',onConsole); return res; }
|
||||
|
||||
// Collect elements via evaluate
|
||||
const data = await page.evaluate(() => {
|
||||
const out={buttons:[],tables:[],modals:[]};
|
||||
document.querySelectorAll('button, a.btn, .btn, [data-bs-toggle="modal"]').forEach(el => {
|
||||
const rect=el.getBoundingClientRect();
|
||||
if(rect.width<=0||rect.height<=0) return;
|
||||
const txt=(el.textContent||'').trim();
|
||||
if(!txt) return;
|
||||
out.buttons.push({text:txt.substring(0,60), target:el.getAttribute('data-bs-target')||''});
|
||||
});
|
||||
document.querySelectorAll('table').forEach(t => {
|
||||
if(t.offsetParent===null) return;
|
||||
out.tables.push({rows:t.querySelectorAll('tbody tr').length});
|
||||
});
|
||||
document.querySelectorAll('.modal').forEach(m => {
|
||||
const t=m.querySelector('.modal-title');
|
||||
out.modals.push({id:m.id, title:t?(t.textContent||'').trim().substring(0,60):''});
|
||||
});
|
||||
return out;
|
||||
});
|
||||
res.buttons=data.buttons;
|
||||
res.tables=data.tables;
|
||||
res.modals=data.modals;
|
||||
|
||||
// Test add modals by clicking "Añadir" / "Nuevo" / "Agregar"
|
||||
const addWords=['anadir','nuevo','nueva','crear','agregar'];
|
||||
const addTriggers=data.buttons.filter(b => includesAny(b.text, addWords));
|
||||
for(const trig of addTriggers) {
|
||||
try {
|
||||
const clicked = await page.evaluate((text) => {
|
||||
const els=document.querySelectorAll('button, a.btn, .btn');
|
||||
for(const el of els) {
|
||||
const t=(el.textContent||'').trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'');
|
||||
const q=text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'');
|
||||
if(t.includes(q)) { el.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
}, trig.text);
|
||||
if(!clicked) { res.actions.push({type:'btn_not_found', trigger:trig.text}); continue; }
|
||||
await page.waitForTimeout(1500);
|
||||
const modal = await page.evaluate(() => {
|
||||
const m=document.querySelector('.modal.show');
|
||||
return m?{id:m.id, title:(m.querySelector('.modal-title')?.textContent||'').trim()}:null;
|
||||
});
|
||||
if(modal) {
|
||||
res.actions.push({type:'modal_opened', trigger:trig.text, modal_id:modal.id, modal_title:modal.title, opened:true});
|
||||
await page.evaluate(() => {
|
||||
const close=document.querySelector('.modal.show [data-bs-dismiss="modal"], .modal.show .btn-close');
|
||||
if(close) close.click();
|
||||
});
|
||||
await page.waitForTimeout(700);
|
||||
} else {
|
||||
res.actions.push({type:'modal_not_opened', trigger:trig.text, opened:false});
|
||||
}
|
||||
} catch(e) { res.actions.push({type:'modal_error', trigger:trig.text, error:e.message}); }
|
||||
}
|
||||
|
||||
// Row actions
|
||||
const rowWords=['ver','editar','eliminar','detalles'];
|
||||
for(const rw of rowWords) {
|
||||
try {
|
||||
await page.evaluate((w) => {
|
||||
const els=document.querySelectorAll('button, a.btn, .table-action-btn');
|
||||
for(const el of els) {
|
||||
const t=(el.textContent||'').trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'');
|
||||
if(t===w) { el.click(); return; }
|
||||
}
|
||||
}, rw);
|
||||
await page.waitForTimeout(1000);
|
||||
const overlay=await page.evaluate(() => document.querySelector('.modal.show, .swal2-modal, .toast')!==null);
|
||||
res.actions.push({type:'row_action', trigger:rw, overlay});
|
||||
if(overlay) {
|
||||
await page.evaluate(() => {
|
||||
const c=document.querySelector('.modal.show [data-bs-dismiss="modal"], .swal2-close, .toast .btn-close');
|
||||
if(c) c.click();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
} catch(e) { res.actions.push({type:'row_action_error', trigger:rw, error:e.message}); }
|
||||
}
|
||||
|
||||
// Filter
|
||||
const filterBtn = data.buttons.find(b => includesAny(b.text, ['filtrar','buscar','aplicar']));
|
||||
if(filterBtn) {
|
||||
try {
|
||||
await page.evaluate((ft) => {
|
||||
const els=document.querySelectorAll('button, a.btn, .btn');
|
||||
for(const el of els) {
|
||||
const t=(el.textContent||'').trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'');
|
||||
if(t.includes(ft)) { el.click(); return; }
|
||||
}
|
||||
}, filterBtn.text);
|
||||
await page.waitForTimeout(1000);
|
||||
res.actions.push({type:'filter_click', trigger:filterBtn.text});
|
||||
} catch(e) { res.actions.push({type:'filter_error', trigger:filterBtn.text, error:e.message}); }
|
||||
}
|
||||
|
||||
// Screenshot
|
||||
const ss=path.join(SCREENSHOT_DIR, `${sec.name}.png`);
|
||||
await page.screenshot({path:ss, fullPage:false});
|
||||
res.screenshot=ss;
|
||||
|
||||
page.off('console', onConsole);
|
||||
res.consoleErrors=[...new Set(consoleMsgs)];
|
||||
|
||||
const critical=res.consoleErrors.some(e => /uncaught|typeerror|referenceerror|cannot read|failed to fetch|networkerror/i.test(e));
|
||||
const modalOk=res.actions.filter(a => a.type==='modal_opened' && a.opened).length;
|
||||
if(critical) res.verdict='fail';
|
||||
else if(sec.expected_modals>0 && modalOk===0) res.verdict='warning';
|
||||
else res.verdict='pass';
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
ensure();
|
||||
console.log(`🔍 Admin Panel Deep Functional Test v6 — ${TARGET_URL}`);
|
||||
|
||||
const browser=await chromium.launch({headless:true, executablePath:EXE_PATH});
|
||||
const page=await browser.newPage({viewport:{width:1920,height:1080}});
|
||||
|
||||
console.log('\n🔐 Logging in...');
|
||||
await page.goto(`${TARGET_URL}/login`, {waitUntil:'domcontentloaded', timeout:15000});
|
||||
await page.waitForTimeout(1000);
|
||||
await page.fill('input#email', ADMIN_EMAIL);
|
||||
await page.fill('input#password', ADMIN_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
console.log(`✅ Logged in: ${page.url()}`);
|
||||
|
||||
// Navigate to admin SPA root once
|
||||
await page.goto(`${TARGET_URL}/admin`, {waitUntil:'domcontentloaded', timeout:15000});
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
for(const sec of SECTIONS) {
|
||||
const r=await testSection(page, sec);
|
||||
results.sections.push(r);
|
||||
results.summary[r.verdict==='pass'?'passed':r.verdict==='warning'?'warnings':'failed']++;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const rp=path.join(REPORT_DIR, 'admin-panel-deep-report.json');
|
||||
fs.writeFileSync(rp, JSON.stringify(results, null, 2));
|
||||
console.log(`\n📊 Report: ${rp}`);
|
||||
|
||||
console.log(`\n========== RESULTS ==========`);
|
||||
for(const s of results.sections) {
|
||||
const icon=s.verdict==='pass'?'✅':s.verdict==='warning'?'⚠️':'❌';
|
||||
const modalOk=s.actions.filter(a => a.type==='modal_opened' && a.opened).length;
|
||||
const modalTotal=s.actions.filter(a => a.type==='modal_opened').length;
|
||||
console.log(`${icon} ${s.label}: ${s.verdict} | btns=${s.buttons.length} modals=${modalOk}/${modalTotal} tables=${s.tables.length} errors=${s.consoleErrors.length}`);
|
||||
for(const a of s.actions) {
|
||||
const ai=a.type==='modal_opened'?(a.opened?'✅':'❌'):a.type==='row_action'?'👁️':a.type==='filter_click'?'🔍':'⚠️';
|
||||
console.log(` ${ai} ${a.type}: ${a.trigger||''} ${a.modal_title||''}${a.error?' [error: '+a.error+']':''}`);
|
||||
}
|
||||
for(const e of s.consoleErrors.slice(0,3)) console.log(` ❌ ${e.substring(0,120)}`);
|
||||
}
|
||||
console.log(`=============================`);
|
||||
console.log(`✅ ${results.summary.passed} ⚠️ ${results.summary.warnings} ❌ ${results.summary.failed}`);
|
||||
|
||||
process.exit(results.summary.failed>0?1:0);
|
||||
}
|
||||
|
||||
run().catch(e => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user