From d5668193676e77851a736822d92e35e3ce1c2b8d Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 25 Nov 2025 00:24:05 +0300 Subject: [PATCH] Feat: Add inline category creation in catalog with clickable product names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added inline category creation functionality to catalog page with user-friendly interface: - Inline input fields for creating root and nested categories - '+' button in category tree header for root categories - '+' icon on hover for each category node to create subcategories - Clickable product/kit names in catalog grid and list views - AJAX API endpoint for category creation with validation - User-friendly error messages for duplicate names and constraints - Clean implementation using clearTimeout pattern to prevent duplicate requests Technical details: - New API endpoint: POST /products/api/categories/create/ - Auto-generates slug and SKU for new categories - Validates uniqueness, parent existence, and circular references - Single-request submission with proper race condition handling - Removes debug logging for production-ready code πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../products/static/products/js/catalog.js | 181 ++++++++++++++++++ .../products/templates/products/catalog.html | 29 ++- .../templates/products/catalog_tree_node.html | 1 + myproject/products/urls.py | 1 + myproject/products/views/api_views.py | 139 ++++++++++++++ 5 files changed, 344 insertions(+), 7 deletions(-) diff --git a/myproject/products/static/products/js/catalog.js b/myproject/products/static/products/js/catalog.js index bc40808..d48eebc 100644 --- a/myproject/products/static/products/js/catalog.js +++ b/myproject/products/static/products/js/catalog.js @@ -220,6 +220,187 @@ document.addEventListener('DOMContentLoaded', function() { }); }); + // ======================================== + // Inline-созданиС ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΉ + // ======================================== + + // Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΊΠΎΡ€Π½Π΅Π²ΠΎΠΉ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ + const addRootCategoryBtn = document.getElementById('add-root-category-btn'); + if (addRootCategoryBtn) { + addRootCategoryBtn.addEventListener('click', function(e) { + e.stopPropagation(); + showCategoryInput(null); + }); + } + + // Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΏΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ (Ρ‡Π΅Ρ€Π΅Π· ΠΈΠΊΠΎΠ½ΠΊΡƒ "+") + document.querySelectorAll('.category-add-child-btn').forEach(btn => { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + const parentId = this.dataset.parentId; + const categoryNode = this.closest('.category-node'); + showCategoryInput(parentId, categoryNode); + }); + }); + + // Ѐункция ΠΏΠΎΠΊΠ°Π·Π° inline ΠΈΠ½ΠΏΡƒΡ‚Π° для создания ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ + function showCategoryInput(parentId = null, parentNode = null) { + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, Π½Π΅Ρ‚ Π»ΠΈ ΡƒΠΆΠ΅ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ ΠΈΠ½ΠΏΡƒΡ‚Π° + const existingInput = document.querySelector('.category-create-input'); + if (existingInput) { + existingInput.focus(); + return; + } + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€ для Π½ΠΎΠ²ΠΎΠ³ΠΎ ΠΈΠ½ΠΏΡƒΡ‚Π° + const inputContainer = document.createElement('div'); + inputContainer.className = 'category-node'; + inputContainer.style.cssText = 'padding-left: ' + (parentId ? '1rem' : '0'); + + const inputHeader = document.createElement('div'); + inputHeader.className = 'category-header'; + inputHeader.style.cssText = 'background-color: #e7f1ff;'; + + // Иконка (Ρ‚ΠΎΡ‡ΠΊΠ° для Π½ΠΎΠ²ΠΎΠ³ΠΎ ΡƒΠ·Π»Π°) + const dotIcon = document.createElement('i'); + dotIcon.className = 'bi bi-dot text-muted'; + inputHeader.appendChild(dotIcon); + + // Иконка ΠΏΠ°ΠΏΠΊΠΈ + const folderIcon = document.createElement('i'); + folderIcon.className = 'bi bi-folder2 text-secondary'; + inputHeader.appendChild(folderIcon); + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ input + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'НазваниС ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ...'; + input.className = 'category-create-input flex-grow-1'; + input.style.cssText = 'border: 1px solid #198754; border-radius: 3px; padding: 2px 6px; font-size: 0.9rem; outline: none;'; + inputHeader.appendChild(input); + + inputContainer.appendChild(inputHeader); + + // ВставляСм Π² Π½ΡƒΠΆΠ½ΠΎΠ΅ мСсто + if (parentId && parentNode) { + // ΠŸΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ - вставляСм Π² children ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€ + let childrenContainer = parentNode.querySelector('.category-children'); + + // Если ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€ Π½Π΅ сущСствуСт, создаСм Π΅Π³ΠΎ + if (!childrenContainer) { + childrenContainer = document.createElement('div'); + childrenContainer.className = 'category-children'; + parentNode.appendChild(childrenContainer); + } + + // РаскрываСм родитСля, Ссли свСрнут + if (childrenContainer.classList.contains('d-none')) { + childrenContainer.classList.remove('d-none'); + const toggle = parentNode.querySelector('.category-toggle'); + if (toggle) { + toggle.classList.remove('collapsed'); + } + } + + childrenContainer.insertBefore(inputContainer, childrenContainer.firstChild); + } else { + // ΠšΠΎΡ€Π½Π΅Π²Π°Ρ катСгория - вставляСм Π² Π½Π°Ρ‡Π°Π»ΠΎ Π΄Π΅Ρ€Π΅Π²Π° + const categoryTree = document.querySelector('.category-tree'); + categoryTree.insertBefore(inputContainer, categoryTree.firstChild); + } + + input.focus(); + + // Π€Π»Π°Π³ для прСдотвращСния ΠΏΠΎΠ²Ρ‚ΠΎΡ€Π½ΠΎΠ³ΠΎ Π²Ρ‹Π·ΠΎΠ²Π° + let isSubmitted = false; + let blurTimeoutId = null; + + // Ѐункция создания ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ + const createCategory = async () => { + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, Π½Π΅ Π±Ρ‹Π»Π° Π»ΠΈ ΡƒΠΆΠ΅ ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½Π° Ρ„ΠΎΡ€ΠΌΠ° + if (isSubmitted) return; + + const name = input.value.trim(); + if (!name) { + inputContainer.remove(); + return; + } + + // ΠžΡ‚ΠΌΠ΅Π½ΡΠ΅ΠΌ blur ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ Ссли ΠΎΠ½ Π·Π°ΠΏΠ»Π°Π½ΠΈΡ€ΠΎΠ²Π°Π½ + if (blurTimeoutId) { + clearTimeout(blurTimeoutId); + blurTimeoutId = null; + } + + // УстанавливаСм Ρ„Π»Π°Π³ + isSubmitted = true; + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΡƒ + input.disabled = true; + input.style.opacity = '0.5'; + + try { + const response = await fetch('/products/api/categories/create/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + name: name, + parent_id: parentId + }) + }); + + const data = await response.json(); + + if (data.success) { + location.reload(); + } else { + // УдаляСм input Π”Πž alert + inputContainer.remove(); + alert(data.error || 'Ошибка ΠΏΡ€ΠΈ создании ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ'); + } + } catch (error) { + console.error('Ошибка ΠΏΡ€ΠΈ создании ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ:', error); + inputContainer.remove(); + alert('Ошибка сСти'); + } + }; + + // Ѐункция ΠΎΡ‚ΠΌΠ΅Π½Ρ‹ + const cancelCreate = () => { + if (!isSubmitted) { + inputContainer.remove(); + } + }; + + // Enter - ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + createCategory(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelCreate(); + } + }); + + // ΠŸΠΎΡ‚Π΅Ρ€Ρ фокуса - ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ ΠΈΠ»ΠΈ ΠΎΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ + input.addEventListener('blur', function() { + // НСбольшая Π·Π°Π΄Π΅Ρ€ΠΆΠΊΠ° Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π½Π΅ ΠΊΠΎΠ½Ρ„Π»ΠΈΠΊΡ‚ΠΎΠ²Π°Ρ‚ΡŒ с Enter + blurTimeoutId = setTimeout(() => { + if (!isSubmitted) { + if (input.value.trim()) { + createCategory(); + } else { + cancelCreate(); + } + } + }, 100); + }); + } + // ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ CSRF Ρ‚ΠΎΠΊΠ΅Π½Π° function getCsrfToken() { const cookieValue = document.cookie diff --git a/myproject/products/templates/products/catalog.html b/myproject/products/templates/products/catalog.html index 6923adf..bca7a44 100644 --- a/myproject/products/templates/products/catalog.html +++ b/myproject/products/templates/products/catalog.html @@ -118,8 +118,9 @@ .catalog-list .catalog-item .card-body .mt-1 { margin-top: 0 !important; } - /* Кнопка пСрСимСнования ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ */ - .category-rename-btn { + /* Кнопки управлСния катСгориями (ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½ΠΈΠ΅, Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅ ΠΏΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ) */ + .category-rename-btn, + .category-add-child-btn { opacity: 0; cursor: pointer; color: #6c757d; @@ -127,13 +128,18 @@ padding: 2px 4px; transition: opacity 0.2s, color 0.15s; } - .category-header:hover .category-rename-btn { + .category-header:hover .category-rename-btn, + .category-header:hover .category-add-child-btn { opacity: 0.5; } .category-rename-btn:hover { opacity: 1 !important; color: #0d6efd; } + .category-add-child-btn:hover { + opacity: 1 !important; + color: #198754; + } {% endblock %} @@ -145,9 +151,12 @@
ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ -
- - +
+ +
+ + +
@@ -200,7 +209,13 @@
-
{{ item.name }}
+
+ {% if item.item_type == 'kit' %} + {{ item.name }} + {% else %} + {{ item.name }} + {% endif %} +
{{ item.actual_price|floatformat:0 }} β‚½ {{ item.sku }} diff --git a/myproject/products/templates/products/catalog_tree_node.html b/myproject/products/templates/products/catalog_tree_node.html index 123dc98..1abef12 100644 --- a/myproject/products/templates/products/catalog_tree_node.html +++ b/myproject/products/templates/products/catalog_tree_node.html @@ -16,6 +16,7 @@ {{ products_count|add:kits_count }} {% endif %} {% endwith %} +
diff --git a/myproject/products/urls.py b/myproject/products/urls.py index 8318832..0f2edf5 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -47,6 +47,7 @@ urlpatterns = [ path('api/kits/temporary/create/', views.create_temporary_kit_api, name='api-temporary-kit-create'), path('api/tags/create/', api_views.create_tag_api, name='api-tag-create'), path('api/tags//toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'), + path('api/categories/create/', api_views.create_category_api, name='api-category-create'), path('api/categories//rename/', api_views.rename_category_api, name='api-category-rename'), # Photo processing status API (for AJAX polling) diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index cc3a56c..4fcec45 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -942,3 +942,142 @@ def rename_category_api(request, pk): 'success': False, 'error': f'Ошибка ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½ΠΈΠΈ: {str(e)}' }, status=500) + + +def create_category_api(request): + """ + AJAX endpoint для создания Π½ΠΎΠ²ΠΎΠΉ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ (inline creation). + + ΠŸΡ€ΠΈΠ½ΠΈΠΌΠ°Π΅Ρ‚ JSON: + { + "name": "НазваниС ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ", + "parent_id": 123 # ΠΎΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎ, null для ΠΊΠΎΡ€Π½Π΅Π²ΠΎΠΉ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ + } + + Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ JSON: + { + "success": true, + "category": { + "id": 456, + "name": "НазваниС ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ", + "slug": "nazvanie-kategorii", + "parent_id": 123 ΠΈΠ»ΠΈ null + } + } + """ + if request.method != 'POST': + return JsonResponse({ + 'success': False, + 'error': 'ΠœΠ΅Ρ‚ΠΎΠ΄ Π½Π΅ поддСрТиваСтся' + }, status=405) + + try: + import json + from django.db import IntegrityError + + data = json.loads(request.body) + name = data.get('name', '').strip() + parent_id = data.get('parent_id') + + # Валидация названия + if not name: + return JsonResponse({ + 'success': False, + 'error': 'НазваниС ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ пустым' + }, status=400) + + if len(name) > 200: + return JsonResponse({ + 'success': False, + 'error': 'НазваниС слишком Π΄Π»ΠΈΠ½Π½ΠΎΠ΅ (максимум 200 символов)' + }, status=400) + + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Ρ€ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΡΠΊΡƒΡŽ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡŽ, Ссли ΡƒΠΊΠ°Π·Π°Π½Π° + parent = None + if parent_id: + try: + parent = ProductCategory.objects.get(pk=parent_id) + if not parent.is_active: + return JsonResponse({ + 'success': False, + 'error': 'Π ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΡΠΊΠ°Ρ катСгория Π½Π΅Π°ΠΊΡ‚ΠΈΠ²Π½Π°' + }, status=400) + except ProductCategory.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': 'Π ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΡΠΊΠ°Ρ катСгория Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°' + }, status=404) + + # Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Π½ΠΎΠ²ΡƒΡŽ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡŽ + category = ProductCategory( + name=name, + parent=parent, + is_active=True + ) + + # save() автоматичСски сгСнСрируСт slug ΠΈ sku + category.save() + + return JsonResponse({ + 'success': True, + 'category': { + 'id': category.pk, + 'name': category.name, + 'slug': category.slug, + 'parent_id': category.parent_id + } + }) + + except IntegrityError as e: + # ΠžΠΏΡ€Π΅Π΄Π΅Π»ΡΠ΅ΠΌ Ρ‚ΠΈΠΏ ошибки ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ + error_str = str(e).lower() + if 'unique_active_category_name' in error_str or 'unique constraint' in error_str: + error_message = 'ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ с Ρ‚Π°ΠΊΠΈΠΌ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ΠΌ ΡƒΠΆΠ΅ сущСствуСт' + elif 'sku' in error_str: + error_message = 'Ошибка ΠΏΡ€ΠΈ Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ Π°Ρ€Ρ‚ΠΈΠΊΡƒΠ»Π°. ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ Π΅Ρ‰Ρ‘ Ρ€Π°Π·' + else: + error_message = 'Ошибка: катСгория с Ρ‚Π°ΠΊΠΈΠΌΠΈ Π΄Π°Π½Π½Ρ‹ΠΌΠΈ ΡƒΠΆΠ΅ сущСствуСт' + + return JsonResponse({ + 'success': False, + 'error': error_message + }, status=400) + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'НСкоррСктный JSON' + }, status=400) + except ValidationError as e: + # ValidationError ΠΌΠΎΠΆΠ΅Ρ‚ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ ΡΠ»ΠΎΠ²Π°Ρ€ΡŒ с ошибками + if hasattr(e, 'message_dict'): + # ИзвлСкаСм ΠΏΠ΅Ρ€Π²ΠΎΠ΅ сообщСниС ΠΈΠ· словаря + error_messages = [] + for field, messages in e.message_dict.items(): + if isinstance(messages, list): + error_messages.extend(messages) + else: + error_messages.append(str(messages)) + error_text = ' '.join(error_messages) + elif hasattr(e, 'messages'): + error_text = ' '.join(e.messages) + else: + error_text = str(e) + + # ЗамСняСм тСхничСскиС сообщСния Π½Π° понятныС + if 'unique_active_category_name' in error_text.lower(): + error_text = 'ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ с Ρ‚Π°ΠΊΠΈΠΌ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ΠΌ ΡƒΠΆΠ΅ сущСствуСт' + elif 'цикличСская ссылка' in error_text.lower(): + error_text = 'НСвозмоТно ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡŽ: ΠΎΠ±Π½Π°Ρ€ΡƒΠΆΠ΅Π½Π° цикличСская ссылка' + elif 'слишком глубокая Π²Π»ΠΎΠΆΠ΅Π½Π½ΠΎΡΡ‚ΡŒ' in error_text.lower(): + error_text = 'ΠŸΡ€Π΅Π²Ρ‹ΡˆΠ΅Π½Π° максимальная Π³Π»ΡƒΠ±ΠΈΠ½Π° влоТСнности ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΉ' + + return JsonResponse({ + 'success': False, + 'error': error_text + }, status=400) + except Exception as e: + logger.error(f'Ошибка ΠΏΡ€ΠΈ создании ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ: {str(e)}') + return JsonResponse({ + 'success': False, + 'error': f'Ошибка ΠΏΡ€ΠΈ создании ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ: {str(e)}' + }, status=500)