fixes but still nto fixed
This commit is contained in:
@@ -757,20 +757,109 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
def filter_options(self, request):
|
def filter_options(self, request):
|
||||||
"""
|
"""
|
||||||
Get distinct filter values from current data.
|
Get distinct filter values from current data.
|
||||||
Returns only countries and statuses that exist in the current site's keywords.
|
Returns only values that exist in the filtered queryset.
|
||||||
|
Supports cascading filters - pass current filter values to get remaining options.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
# Get distinct countries from seed_keyword (use set for proper deduplication)
|
# Apply current filters to get cascading options
|
||||||
countries = list(set(queryset.values_list('seed_keyword__country', flat=True)))
|
# Each filter's options are based on data that matches OTHER filters
|
||||||
countries = sorted([c for c in countries if c]) # Sort and filter nulls
|
status_filter = request.query_params.get('status')
|
||||||
|
country_filter = request.query_params.get('country')
|
||||||
|
cluster_filter = request.query_params.get('cluster_id')
|
||||||
|
difficulty_min = request.query_params.get('difficulty_min')
|
||||||
|
difficulty_max = request.query_params.get('difficulty_max')
|
||||||
|
|
||||||
|
# Base queryset for each filter option calculation
|
||||||
|
# For countries: apply status, cluster, difficulty filters
|
||||||
|
countries_qs = queryset
|
||||||
|
if status_filter:
|
||||||
|
countries_qs = countries_qs.filter(status=status_filter)
|
||||||
|
if cluster_filter:
|
||||||
|
countries_qs = countries_qs.filter(cluster_id=cluster_filter)
|
||||||
|
if difficulty_min is not None:
|
||||||
|
try:
|
||||||
|
countries_qs = countries_qs.filter(
|
||||||
|
Q(difficulty_override__gte=int(difficulty_min)) |
|
||||||
|
Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min))
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if difficulty_max is not None:
|
||||||
|
try:
|
||||||
|
countries_qs = countries_qs.filter(
|
||||||
|
Q(difficulty_override__lte=int(difficulty_max)) |
|
||||||
|
Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max))
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# For statuses: apply country, cluster, difficulty filters
|
||||||
|
statuses_qs = queryset
|
||||||
|
if country_filter:
|
||||||
|
statuses_qs = statuses_qs.filter(seed_keyword__country=country_filter)
|
||||||
|
if cluster_filter:
|
||||||
|
statuses_qs = statuses_qs.filter(cluster_id=cluster_filter)
|
||||||
|
if difficulty_min is not None:
|
||||||
|
try:
|
||||||
|
statuses_qs = statuses_qs.filter(
|
||||||
|
Q(difficulty_override__gte=int(difficulty_min)) |
|
||||||
|
Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min))
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if difficulty_max is not None:
|
||||||
|
try:
|
||||||
|
statuses_qs = statuses_qs.filter(
|
||||||
|
Q(difficulty_override__lte=int(difficulty_max)) |
|
||||||
|
Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max))
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# For clusters: apply status, country, difficulty filters
|
||||||
|
clusters_qs = queryset
|
||||||
|
if status_filter:
|
||||||
|
clusters_qs = clusters_qs.filter(status=status_filter)
|
||||||
|
if country_filter:
|
||||||
|
clusters_qs = clusters_qs.filter(seed_keyword__country=country_filter)
|
||||||
|
if difficulty_min is not None:
|
||||||
|
try:
|
||||||
|
clusters_qs = clusters_qs.filter(
|
||||||
|
Q(difficulty_override__gte=int(difficulty_min)) |
|
||||||
|
Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min))
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if difficulty_max is not None:
|
||||||
|
try:
|
||||||
|
clusters_qs = clusters_qs.filter(
|
||||||
|
Q(difficulty_override__lte=int(difficulty_max)) |
|
||||||
|
Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max))
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# For difficulties: apply status, country, cluster filters
|
||||||
|
difficulties_qs = queryset
|
||||||
|
if status_filter:
|
||||||
|
difficulties_qs = difficulties_qs.filter(status=status_filter)
|
||||||
|
if country_filter:
|
||||||
|
difficulties_qs = difficulties_qs.filter(seed_keyword__country=country_filter)
|
||||||
|
if cluster_filter:
|
||||||
|
difficulties_qs = difficulties_qs.filter(cluster_id=cluster_filter)
|
||||||
|
|
||||||
|
# Get distinct countries
|
||||||
|
countries = list(set(countries_qs.values_list('seed_keyword__country', flat=True)))
|
||||||
|
countries = sorted([c for c in countries if c])
|
||||||
|
|
||||||
# Map country codes to display names
|
|
||||||
from igny8_core.auth.models import SeedKeyword
|
from igny8_core.auth.models import SeedKeyword
|
||||||
country_choices = dict(SeedKeyword.COUNTRY_CHOICES)
|
country_choices = dict(SeedKeyword.COUNTRY_CHOICES)
|
||||||
country_options = [
|
country_options = [
|
||||||
@@ -778,9 +867,9 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
for c in countries
|
for c in countries
|
||||||
]
|
]
|
||||||
|
|
||||||
# Get distinct statuses (use set for proper deduplication)
|
# Get distinct statuses from filtered queryset
|
||||||
statuses = list(set(queryset.values_list('status', flat=True)))
|
statuses = list(set(statuses_qs.values_list('status', flat=True)))
|
||||||
statuses = sorted([s for s in statuses if s]) # Sort and filter nulls
|
statuses = sorted([s for s in statuses if s])
|
||||||
status_labels = {
|
status_labels = {
|
||||||
'new': 'New',
|
'new': 'New',
|
||||||
'mapped': 'Mapped',
|
'mapped': 'Mapped',
|
||||||
@@ -790,9 +879,9 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
for s in statuses
|
for s in statuses
|
||||||
]
|
]
|
||||||
|
|
||||||
# Get distinct clusters (use set for proper deduplication)
|
# Get distinct clusters from filtered queryset
|
||||||
cluster_ids = list(set(
|
cluster_ids = list(set(
|
||||||
queryset.exclude(cluster_id__isnull=True)
|
clusters_qs.exclude(cluster_id__isnull=True)
|
||||||
.values_list('cluster_id', flat=True)
|
.values_list('cluster_id', flat=True)
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -802,11 +891,48 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
for c in clusters
|
for c in clusters
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Get distinct difficulty levels from filtered queryset (mapped to 1-5 scale)
|
||||||
|
# Difficulty is stored as 0-100 in seed_keyword, mapped to 1-5 scale
|
||||||
|
from django.db.models import Case, When, Value, IntegerField
|
||||||
|
|
||||||
|
# Get effective difficulty (override or seed_keyword)
|
||||||
|
difficulty_values = difficulties_qs.annotate(
|
||||||
|
effective_difficulty=Coalesce('difficulty_override', 'seed_keyword__difficulty')
|
||||||
|
).exclude(effective_difficulty__isnull=True).values_list('effective_difficulty', flat=True)
|
||||||
|
|
||||||
|
# Map raw difficulty (0-100) to 1-5 scale and find unique values
|
||||||
|
difficulty_levels = set()
|
||||||
|
for d in difficulty_values:
|
||||||
|
if d is not None:
|
||||||
|
if d <= 10:
|
||||||
|
difficulty_levels.add(1)
|
||||||
|
elif d <= 30:
|
||||||
|
difficulty_levels.add(2)
|
||||||
|
elif d <= 50:
|
||||||
|
difficulty_levels.add(3)
|
||||||
|
elif d <= 70:
|
||||||
|
difficulty_levels.add(4)
|
||||||
|
else:
|
||||||
|
difficulty_levels.add(5)
|
||||||
|
|
||||||
|
difficulty_labels = {
|
||||||
|
1: '1 - Very Easy',
|
||||||
|
2: '2 - Easy',
|
||||||
|
3: '3 - Medium',
|
||||||
|
4: '4 - Hard',
|
||||||
|
5: '5 - Very Hard',
|
||||||
|
}
|
||||||
|
difficulty_options = [
|
||||||
|
{'value': str(d), 'label': difficulty_labels[d]}
|
||||||
|
for d in sorted(difficulty_levels)
|
||||||
|
]
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'countries': country_options,
|
'countries': country_options,
|
||||||
'statuses': status_options,
|
'statuses': status_options,
|
||||||
'clusters': cluster_options,
|
'clusters': cluster_options,
|
||||||
|
'difficulties': difficulty_options,
|
||||||
},
|
},
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
@@ -1130,6 +1256,45 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
return success_response(data={'deleted_count': deleted_count}, request=request)
|
return success_response(data={'deleted_count': deleted_count}, request=request)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
|
||||||
|
def filter_options(self, request):
|
||||||
|
"""
|
||||||
|
Get distinct filter values from current data.
|
||||||
|
Returns only statuses that exist in the current site's clusters.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
# Get distinct statuses
|
||||||
|
statuses = list(set(queryset.values_list('status', flat=True)))
|
||||||
|
statuses = sorted([s for s in statuses if s])
|
||||||
|
status_labels = {
|
||||||
|
'new': 'New',
|
||||||
|
'mapped': 'Mapped',
|
||||||
|
}
|
||||||
|
status_options = [
|
||||||
|
{'value': s, 'label': status_labels.get(s, s.title())}
|
||||||
|
for s in statuses
|
||||||
|
]
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
'statuses': status_options,
|
||||||
|
},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in filter_options: {str(e)}", exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error=f'Failed to fetch filter options: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
|
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
|
||||||
def auto_generate_ideas(self, request):
|
def auto_generate_ideas(self, request):
|
||||||
"""Auto-generate ideas for clusters using IdeasService"""
|
"""Auto-generate ideas for clusters using IdeasService"""
|
||||||
@@ -1308,6 +1473,90 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
serializer.save(account=account, site=site, sector=sector)
|
serializer.save(account=account, site=site, sector=sector)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
|
||||||
|
def filter_options(self, request):
|
||||||
|
"""
|
||||||
|
Get distinct filter values from current data.
|
||||||
|
Returns only values that exist in the current site's content ideas.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
# Get distinct statuses
|
||||||
|
statuses = list(set(queryset.values_list('status', flat=True)))
|
||||||
|
statuses = sorted([s for s in statuses if s])
|
||||||
|
status_labels = {
|
||||||
|
'new': 'New',
|
||||||
|
'queued': 'Queued',
|
||||||
|
'completed': 'Completed',
|
||||||
|
}
|
||||||
|
status_options = [
|
||||||
|
{'value': s, 'label': status_labels.get(s, s.title())}
|
||||||
|
for s in statuses
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get distinct content_types
|
||||||
|
content_types = list(set(queryset.values_list('content_type', flat=True)))
|
||||||
|
content_types = sorted([t for t in content_types if t])
|
||||||
|
type_labels = {
|
||||||
|
'post': 'Post',
|
||||||
|
'page': 'Page',
|
||||||
|
'product': 'Product',
|
||||||
|
'taxonomy': 'Taxonomy',
|
||||||
|
}
|
||||||
|
content_type_options = [
|
||||||
|
{'value': t, 'label': type_labels.get(t, t.title())}
|
||||||
|
for t in content_types
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get distinct content_structures
|
||||||
|
structures = list(set(queryset.values_list('content_structure', flat=True)))
|
||||||
|
structures = sorted([s for s in structures if s])
|
||||||
|
structure_labels = {
|
||||||
|
'article': 'Article', 'guide': 'Guide', 'comparison': 'Comparison',
|
||||||
|
'review': 'Review', 'listicle': 'Listicle', 'landing_page': 'Landing Page',
|
||||||
|
'business_page': 'Business Page', 'service_page': 'Service Page',
|
||||||
|
'general': 'General', 'cluster_hub': 'Cluster Hub',
|
||||||
|
'product_page': 'Product Page', 'category_archive': 'Category Archive',
|
||||||
|
'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive',
|
||||||
|
}
|
||||||
|
content_structure_options = [
|
||||||
|
{'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())}
|
||||||
|
for s in structures
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get distinct clusters with ideas
|
||||||
|
cluster_ids = list(set(
|
||||||
|
queryset.exclude(keyword_cluster_id__isnull=True)
|
||||||
|
.values_list('keyword_cluster_id', flat=True)
|
||||||
|
))
|
||||||
|
clusters = Clusters.objects.filter(id__in=cluster_ids).values('id', 'name').order_by('name')
|
||||||
|
cluster_options = [
|
||||||
|
{'value': str(c['id']), 'label': c['name']}
|
||||||
|
for c in clusters
|
||||||
|
]
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
'statuses': status_options,
|
||||||
|
'content_types': content_type_options,
|
||||||
|
'content_structures': content_structure_options,
|
||||||
|
'clusters': cluster_options,
|
||||||
|
},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in filter_options: {str(e)}", exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error=f'Failed to fetch filter options: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
|
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
|
||||||
def bulk_delete(self, request):
|
def bulk_delete(self, request):
|
||||||
"""Bulk delete content ideas"""
|
"""Bulk delete content ideas"""
|
||||||
|
|||||||
@@ -824,6 +824,108 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
else:
|
else:
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
|
||||||
|
def filter_options(self, request):
|
||||||
|
"""
|
||||||
|
Get distinct filter values from current data.
|
||||||
|
Returns only values that exist in the current site's content.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
# Get distinct statuses
|
||||||
|
statuses = list(set(queryset.values_list('status', flat=True)))
|
||||||
|
statuses = sorted([s for s in statuses if s])
|
||||||
|
status_labels = {
|
||||||
|
'draft': 'Draft',
|
||||||
|
'review': 'Review',
|
||||||
|
'approved': 'Approved',
|
||||||
|
'published': 'Published',
|
||||||
|
}
|
||||||
|
status_options = [
|
||||||
|
{'value': s, 'label': status_labels.get(s, s.title())}
|
||||||
|
for s in statuses
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get distinct site_status
|
||||||
|
site_statuses = list(set(queryset.values_list('site_status', flat=True)))
|
||||||
|
site_statuses = sorted([s for s in site_statuses if s])
|
||||||
|
site_status_labels = {
|
||||||
|
'not_published': 'Not Published',
|
||||||
|
'scheduled': 'Scheduled',
|
||||||
|
'publishing': 'Publishing',
|
||||||
|
'published': 'Published',
|
||||||
|
'failed': 'Failed',
|
||||||
|
}
|
||||||
|
site_status_options = [
|
||||||
|
{'value': s, 'label': site_status_labels.get(s, s.replace('_', ' ').title())}
|
||||||
|
for s in site_statuses
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get distinct content_types
|
||||||
|
content_types = list(set(queryset.values_list('content_type', flat=True)))
|
||||||
|
content_types = sorted([t for t in content_types if t])
|
||||||
|
type_labels = {
|
||||||
|
'post': 'Post',
|
||||||
|
'page': 'Page',
|
||||||
|
'product': 'Product',
|
||||||
|
'taxonomy': 'Taxonomy',
|
||||||
|
}
|
||||||
|
content_type_options = [
|
||||||
|
{'value': t, 'label': type_labels.get(t, t.title())}
|
||||||
|
for t in content_types
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get distinct content_structures
|
||||||
|
structures = list(set(queryset.values_list('content_structure', flat=True)))
|
||||||
|
structures = sorted([s for s in structures if s])
|
||||||
|
structure_labels = {
|
||||||
|
'article': 'Article', 'guide': 'Guide', 'comparison': 'Comparison',
|
||||||
|
'review': 'Review', 'listicle': 'Listicle', 'landing_page': 'Landing Page',
|
||||||
|
'business_page': 'Business Page', 'service_page': 'Service Page',
|
||||||
|
'general': 'General', 'cluster_hub': 'Cluster Hub',
|
||||||
|
'product_page': 'Product Page', 'category_archive': 'Category Archive',
|
||||||
|
'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive',
|
||||||
|
}
|
||||||
|
content_structure_options = [
|
||||||
|
{'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())}
|
||||||
|
for s in structures
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get distinct sources
|
||||||
|
sources = list(set(queryset.values_list('source', flat=True)))
|
||||||
|
sources = sorted([s for s in sources if s])
|
||||||
|
source_labels = {
|
||||||
|
'igny8': 'IGNY8',
|
||||||
|
'wordpress': 'WordPress',
|
||||||
|
}
|
||||||
|
source_options = [
|
||||||
|
{'value': s, 'label': source_labels.get(s, s.title())}
|
||||||
|
for s in sources
|
||||||
|
]
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
'statuses': status_options,
|
||||||
|
'site_statuses': site_status_options,
|
||||||
|
'content_types': content_type_options,
|
||||||
|
'content_structures': content_structure_options,
|
||||||
|
'sources': source_options,
|
||||||
|
},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in filter_options: {str(e)}", exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error=f'Failed to fetch filter options: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
|
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
|
||||||
def bulk_delete(self, request):
|
def bulk_delete(self, request):
|
||||||
"""Bulk delete content"""
|
"""Bulk delete content"""
|
||||||
|
|||||||
351
docs/30-FRONTEND/FILTERS-IMPLEMENTATION.md
Normal file
351
docs/30-FRONTEND/FILTERS-IMPLEMENTATION.md
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
# IGNY8 Filters Implementation
|
||||||
|
|
||||||
|
**Last Updated:** January 15, 2026
|
||||||
|
**Version:** 1.0.0
|
||||||
|
|
||||||
|
> This document describes the current filter implementation across all pages that use the `TablePageTemplate` component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Component Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
Page Component (e.g., Keywords.tsx)
|
||||||
|
└── createPageConfig() function
|
||||||
|
├── Returns: columns, filters, formFields, headerMetrics
|
||||||
|
└── Uses: handlers (state setters, filter values)
|
||||||
|
└── TablePageTemplate.tsx
|
||||||
|
├── Renders filters based on FilterConfig[]
|
||||||
|
├── Manages filter visibility toggle
|
||||||
|
└── Passes filterValues and onFilterChange to children
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Flow
|
||||||
|
|
||||||
|
1. **Page Component** defines filter state (`useState`)
|
||||||
|
2. **Config Function** creates `FilterConfig[]` with options
|
||||||
|
3. **TablePageTemplate** renders filter UI components
|
||||||
|
4. **Filter Change** → `onFilterChange` callback → update state → re-fetch data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter Types
|
||||||
|
|
||||||
|
| Type | Component | Description |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| `text` | `<Input>` | Text search input |
|
||||||
|
| `select` | `<SelectDropdown>` | Single-select dropdown |
|
||||||
|
| `custom` | `customRender()` | Custom component (e.g., volume range) |
|
||||||
|
| `daterange` | (planned) | Date range picker |
|
||||||
|
| `range` | (planned) | Numeric range |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend FilterSet Classes
|
||||||
|
|
||||||
|
### Planner Module (`/backend/igny8_core/modules/planner/views.py`)
|
||||||
|
|
||||||
|
#### KeywordsFilter
|
||||||
|
```python
|
||||||
|
class KeywordsFilter(django_filters.FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Keywords
|
||||||
|
fields = ['status', 'cluster_id', 'seed_keyword__country', 'seed_keyword_id', 'created_at__gte', 'created_at__lte']
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ClustersFilter
|
||||||
|
```python
|
||||||
|
class ClustersFilter(django_filters.FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Clusters
|
||||||
|
fields = ['status', 'created_at__gte', 'created_at__lte']
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ContentIdeasFilter
|
||||||
|
```python
|
||||||
|
class ContentIdeasFilter(django_filters.FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = ContentIdeas
|
||||||
|
fields = ['status', 'keyword_cluster_id', 'content_type', 'content_structure', 'created_at__gte', 'created_at__lte']
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writer Module (`/backend/igny8_core/modules/writer/views.py`)
|
||||||
|
|
||||||
|
#### TasksFilter
|
||||||
|
```python
|
||||||
|
class TasksFilter(django_filters.FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Tasks
|
||||||
|
fields = ['status', 'cluster_id', 'content_type', 'content_structure', 'created_at__gte', 'created_at__lte']
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ImagesFilter
|
||||||
|
```python
|
||||||
|
class ImagesFilter(django_filters.FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Images
|
||||||
|
fields = ['task_id', 'content_id', 'image_type', 'status', 'created_at__gte', 'created_at__lte']
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ContentFilter
|
||||||
|
```python
|
||||||
|
class ContentFilter(django_filters.FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Content
|
||||||
|
fields = ['cluster_id', 'status', 'content_type', 'content_structure', 'source', 'created_at__gte', 'created_at__lte']
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page-by-Page Filter Implementation
|
||||||
|
|
||||||
|
### 1. Keywords Page (`/planner/keywords`)
|
||||||
|
|
||||||
|
**Config File:** `frontend/src/config/pages/keywords.config.tsx`
|
||||||
|
**Page File:** `frontend/src/pages/Planner/Keywords.tsx`
|
||||||
|
|
||||||
|
| Filter Key | Type | Options | Backend Field |
|
||||||
|
|------------|------|---------|---------------|
|
||||||
|
| `search` | text | - | `search` (SearchFilter) |
|
||||||
|
| `status` | select | `new`, `mapped` | `status` |
|
||||||
|
| `country` | select | `US`, `CA`, `GB`, `AE`, `AU`, `IN`, `PK` | `seed_keyword__country` |
|
||||||
|
| `difficulty` | select | `1-5` (mapped labels) | Custom in `get_queryset()` |
|
||||||
|
| `cluster` | select | Dynamic (cluster list) | `cluster_id` |
|
||||||
|
| `volume` | custom | Min/Max inputs | Custom `volume_min`, `volume_max` |
|
||||||
|
|
||||||
|
**Special Handling:**
|
||||||
|
- Difficulty filter uses 1-5 scale that maps to raw score ranges (0-10, 11-30, 31-50, 51-70, 71-100)
|
||||||
|
- Volume range uses custom dropdown with min/max inputs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Clusters Page (`/planner/clusters`)
|
||||||
|
|
||||||
|
**Config File:** `frontend/src/config/pages/clusters.config.tsx`
|
||||||
|
**Page File:** `frontend/src/pages/Planner/Clusters.tsx`
|
||||||
|
|
||||||
|
| Filter Key | Type | Options | Backend Field |
|
||||||
|
|------------|------|---------|---------------|
|
||||||
|
| `search` | text | - | `search` (SearchFilter) |
|
||||||
|
| `status` | select | `new`, `mapped` | `status` |
|
||||||
|
| `difficulty` | select | `1-5` (mapped labels) | Custom filtering |
|
||||||
|
| `volume` | custom | Min/Max inputs | Custom `volume_min`, `volume_max` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Ideas Page (`/planner/ideas`)
|
||||||
|
|
||||||
|
**Config File:** `frontend/src/config/pages/ideas.config.tsx`
|
||||||
|
**Page File:** `frontend/src/pages/Planner/Ideas.tsx`
|
||||||
|
|
||||||
|
| Filter Key | Type | Options | Backend Field |
|
||||||
|
|------------|------|---------|---------------|
|
||||||
|
| `search` | text | - | `search` (SearchFilter) |
|
||||||
|
| `status` | select | `new`, `queued`, `completed` | `status` |
|
||||||
|
| `content_structure` | select | 14 structure types | `content_structure` |
|
||||||
|
| `content_type` | select | `post`, `page`, `product`, `taxonomy` | `content_type` |
|
||||||
|
| `keyword_cluster_id` | select | Dynamic (cluster list) | `keyword_cluster_id` |
|
||||||
|
|
||||||
|
**Structure Options:**
|
||||||
|
- Post: `article`, `guide`, `comparison`, `review`, `listicle`
|
||||||
|
- Page: `landing_page`, `business_page`, `service_page`, `general`, `cluster_hub`
|
||||||
|
- Product: `product_page`
|
||||||
|
- Taxonomy: `category_archive`, `tag_archive`, `attribute_archive`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Content Page (`/writer/content`)
|
||||||
|
|
||||||
|
**Config File:** `frontend/src/config/pages/content.config.tsx`
|
||||||
|
**Page File:** `frontend/src/pages/Writer/Content.tsx`
|
||||||
|
|
||||||
|
| Filter Key | Type | Options | Backend Field |
|
||||||
|
|------------|------|---------|---------------|
|
||||||
|
| `search` | text | - | `search` (SearchFilter) |
|
||||||
|
| `status` | select | `draft`, `published` | `status` |
|
||||||
|
| `content_type` | select | `post`, `page`, `product`, `taxonomy` | `content_type` |
|
||||||
|
| `content_structure` | select | 14 structure types | `content_structure` |
|
||||||
|
| `source` | select | `igny8`, `wordpress` | `source` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Review Page (`/writer/review`)
|
||||||
|
|
||||||
|
**Config File:** `frontend/src/config/pages/review.config.tsx`
|
||||||
|
**Page File:** `frontend/src/pages/Writer/Review.tsx`
|
||||||
|
|
||||||
|
| Filter Key | Type | Options | Backend Field |
|
||||||
|
|------------|------|---------|---------------|
|
||||||
|
| `search` | text | - | `search` (SearchFilter) |
|
||||||
|
| `status` | select | `draft`, `review`, `approved`, `published` | `status` |
|
||||||
|
| `site_status` | select | `not_published`, `scheduled`, `publishing`, `published`, `failed` | `site_status` |
|
||||||
|
| `content_type` | select | From `CONTENT_TYPE_OPTIONS` | `content_type` |
|
||||||
|
| `content_structure` | select | From `ALL_CONTENT_STRUCTURES` | `content_structure` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Approved Page (`/writer/approved`)
|
||||||
|
|
||||||
|
**Config File:** `frontend/src/config/pages/approved.config.tsx`
|
||||||
|
**Page File:** `frontend/src/pages/Writer/Approved.tsx`
|
||||||
|
|
||||||
|
| Filter Key | Type | Options | Backend Field |
|
||||||
|
|------------|------|---------|---------------|
|
||||||
|
| `search` | text | - | `search` (SearchFilter) |
|
||||||
|
| `status` | select | `draft`, `review`, `approved`, `published` | `status` |
|
||||||
|
| `site_status` | select | `not_published`, `scheduled`, `publishing`, `published`, `failed` | `site_status` |
|
||||||
|
| `content_type` | select | From `CONTENT_TYPE_OPTIONS` | `content_type` |
|
||||||
|
| `content_structure` | select | From `ALL_CONTENT_STRUCTURES` | `content_structure` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Constants
|
||||||
|
|
||||||
|
**File:** `frontend/src/config/structureMapping.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const CONTENT_TYPE_OPTIONS = [
|
||||||
|
{ value: 'post', label: 'Post' },
|
||||||
|
{ value: 'page', label: 'Page' },
|
||||||
|
{ value: 'product', label: 'Product' },
|
||||||
|
{ value: 'taxonomy', label: 'Taxonomy' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALL_CONTENT_STRUCTURES = [
|
||||||
|
{ value: 'article', label: 'Article' },
|
||||||
|
{ value: 'guide', label: 'Guide' },
|
||||||
|
{ value: 'comparison', label: 'Comparison' },
|
||||||
|
{ value: 'review', label: 'Review' },
|
||||||
|
{ value: 'listicle', label: 'Listicle' },
|
||||||
|
{ value: 'landing_page', label: 'Landing Page' },
|
||||||
|
{ value: 'business_page', label: 'Business Page' },
|
||||||
|
{ value: 'service_page', label: 'Service Page' },
|
||||||
|
{ value: 'general', label: 'General' },
|
||||||
|
{ value: 'cluster_hub', label: 'Cluster Hub' },
|
||||||
|
{ value: 'product_page', label: 'Product Page' },
|
||||||
|
{ value: 'category_archive', label: 'Category Archive' },
|
||||||
|
{ value: 'tag_archive', label: 'Tag Archive' },
|
||||||
|
{ value: 'attribute_archive', label: 'Attribute Archive' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Difficulty Mapping
|
||||||
|
|
||||||
|
**File:** `frontend/src/utils/difficulty.ts`
|
||||||
|
|
||||||
|
The difficulty filter uses a 1-5 scale with human-readable labels:
|
||||||
|
|
||||||
|
| Value | Label | Raw Score Range |
|
||||||
|
|-------|-------|-----------------|
|
||||||
|
| 1 | Very Easy | 0-10 |
|
||||||
|
| 2 | Easy | 11-30 |
|
||||||
|
| 3 | Medium | 31-50 |
|
||||||
|
| 4 | Hard | 51-70 |
|
||||||
|
| 5 | Very Hard | 71-100 |
|
||||||
|
|
||||||
|
**Important:** The database stores raw SEO difficulty scores (0-100), but the UI displays and filters using the 1-5 scale. The mapping must be consistent between frontend display and backend filtering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter State Management Pattern
|
||||||
|
|
||||||
|
Each page follows this pattern:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. Define filter state
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [difficultyFilter, setDifficultyFilter] = useState('');
|
||||||
|
|
||||||
|
// 2. Pass to config function
|
||||||
|
const config = createPageConfig({
|
||||||
|
searchTerm,
|
||||||
|
setSearchTerm,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
// ...handlers
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Config returns filters array
|
||||||
|
filters: [
|
||||||
|
{ key: 'search', type: 'text', placeholder: '...' },
|
||||||
|
{ key: 'status', type: 'select', options: [...] },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 4. TablePageTemplate renders filters
|
||||||
|
// 5. onFilterChange triggers state update
|
||||||
|
// 6. useEffect with dependencies re-fetches data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Query Parameters
|
||||||
|
|
||||||
|
When filters are applied, the frontend constructs API calls like:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/planner/keywords/?status=new&cluster_id=123&search=term&page=1&page_size=50
|
||||||
|
GET /api/v1/planner/clusters/?status=mapped&difficulty=3&volume_min=100
|
||||||
|
GET /api/v1/planner/ideas/?content_structure=guide&content_type=post
|
||||||
|
GET /api/v1/writer/content/?status=draft&source=igny8
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TablePageTemplate Filter Rendering
|
||||||
|
|
||||||
|
The template handles filter rendering in `renderFiltersRow()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{filters.map((filter) => {
|
||||||
|
if (filter.type === 'text') {
|
||||||
|
return <Input ... />;
|
||||||
|
}
|
||||||
|
if (filter.type === 'select') {
|
||||||
|
return <SelectDropdown ... />;
|
||||||
|
}
|
||||||
|
if (filter.type === 'custom' && filter.customRender) {
|
||||||
|
return filter.customRender();
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Limitations
|
||||||
|
|
||||||
|
1. **Static Options**: Filter options are hardcoded in config files, not dynamically loaded from backend
|
||||||
|
2. **No Cascading**: Changing one filter doesn't update available options in other filters
|
||||||
|
3. **Difficulty Mapping**: Backend filters by exact match, not by mapped ranges
|
||||||
|
4. **No Filter Persistence**: Filters reset on page navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planned Improvements
|
||||||
|
|
||||||
|
1. **Dynamic Filter Options API**: Backend endpoint to return available options based on current data
|
||||||
|
2. **Cascading Filters**: When status is selected, only show clusters/types that exist with that status
|
||||||
|
3. **URL State**: Persist filter state in URL query parameters
|
||||||
|
4. **Filter Presets**: Save commonly used filter combinations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File References
|
||||||
|
|
||||||
|
| Component | Path |
|
||||||
|
|-----------|------|
|
||||||
|
| TablePageTemplate | `frontend/src/templates/TablePageTemplate.tsx` |
|
||||||
|
| Keywords Config | `frontend/src/config/pages/keywords.config.tsx` |
|
||||||
|
| Clusters Config | `frontend/src/config/pages/clusters.config.tsx` |
|
||||||
|
| Ideas Config | `frontend/src/config/pages/ideas.config.tsx` |
|
||||||
|
| Content Config | `frontend/src/config/pages/content.config.tsx` |
|
||||||
|
| Review Config | `frontend/src/config/pages/review.config.tsx` |
|
||||||
|
| Approved Config | `frontend/src/config/pages/approved.config.tsx` |
|
||||||
|
| Structure Mapping | `frontend/src/config/structureMapping.ts` |
|
||||||
|
| Difficulty Utils | `frontend/src/utils/difficulty.ts` |
|
||||||
|
| Planner Views | `backend/igny8_core/modules/planner/views.py` |
|
||||||
|
| Writer Views | `backend/igny8_core/modules/writer/views.py` |
|
||||||
@@ -94,7 +94,7 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`}>
|
<div className={`relative flex-shrink-0 ${className}`}>
|
||||||
{/* Trigger Button - styled like igny8-select-styled */}
|
{/* Trigger Button - styled like igny8-select-styled */}
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
@@ -102,7 +102,7 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
|
|||||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={`igny8-select-styled w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
|
className={`igny8-select-styled w-auto min-w-[120px] max-w-[360px] appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
|
||||||
className.includes('text-base') ? 'h-11 py-2.5 text-base' : 'h-9 py-2 text-sm'
|
className.includes('text-base') ? 'h-11 py-2.5 text-base' : 'h-9 py-2 text-sm'
|
||||||
} ${
|
} ${
|
||||||
isPlaceholder
|
isPlaceholder
|
||||||
@@ -124,7 +124,7 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
className="absolute z-50 left-0 right-0 mt-1 rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden max-h-60 overflow-y-auto"
|
className="absolute z-50 left-0 mt-1 min-w-full rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden max-h-60 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ export const createIdeasPageConfig = (
|
|||||||
typeFilter: string;
|
typeFilter: string;
|
||||||
setTypeFilter: (value: string) => void;
|
setTypeFilter: (value: string) => void;
|
||||||
setCurrentPage: (page: number) => void;
|
setCurrentPage: (page: number) => void;
|
||||||
|
// Dynamic filter options
|
||||||
|
statusOptions?: Array<{ value: string; label: string }>;
|
||||||
|
contentTypeOptions?: Array<{ value: string; label: string }>;
|
||||||
|
contentStructureOptions?: Array<{ value: string; label: string }>;
|
||||||
|
clusterOptions?: Array<{ value: string; label: string }>;
|
||||||
}
|
}
|
||||||
): IdeasPageConfig => {
|
): IdeasPageConfig => {
|
||||||
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
|
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
|
||||||
@@ -233,9 +238,14 @@ export const createIdeasPageConfig = (
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Status' },
|
{ value: '', label: 'All Status' },
|
||||||
{ value: 'new', label: 'New' },
|
...(handlers.statusOptions && handlers.statusOptions.length > 0
|
||||||
{ value: 'queued', label: 'Queued' },
|
? handlers.statusOptions
|
||||||
{ value: 'completed', label: 'Completed' },
|
: [
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'queued', label: 'Queued' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -244,24 +254,25 @@ export const createIdeasPageConfig = (
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Structures' },
|
{ value: '', label: 'All Structures' },
|
||||||
// Post
|
...(handlers.contentStructureOptions && handlers.contentStructureOptions.length > 0
|
||||||
{ value: 'article', label: 'Article' },
|
? handlers.contentStructureOptions
|
||||||
{ value: 'guide', label: 'Guide' },
|
: [
|
||||||
{ value: 'comparison', label: 'Comparison' },
|
{ value: 'article', label: 'Article' },
|
||||||
{ value: 'review', label: 'Review' },
|
{ value: 'guide', label: 'Guide' },
|
||||||
{ value: 'listicle', label: 'Listicle' },
|
{ value: 'comparison', label: 'Comparison' },
|
||||||
// Page
|
{ value: 'review', label: 'Review' },
|
||||||
{ value: 'landing_page', label: 'Landing Page' },
|
{ value: 'listicle', label: 'Listicle' },
|
||||||
{ value: 'business_page', label: 'Business Page' },
|
{ value: 'landing_page', label: 'Landing Page' },
|
||||||
{ value: 'service_page', label: 'Service Page' },
|
{ value: 'business_page', label: 'Business Page' },
|
||||||
{ value: 'general', label: 'General' },
|
{ value: 'service_page', label: 'Service Page' },
|
||||||
{ value: 'cluster_hub', label: 'Cluster Hub' },
|
{ value: 'general', label: 'General' },
|
||||||
// Product
|
{ value: 'cluster_hub', label: 'Cluster Hub' },
|
||||||
{ value: 'product_page', label: 'Product Page' },
|
{ value: 'product_page', label: 'Product Page' },
|
||||||
// Taxonomy
|
{ value: 'category_archive', label: 'Category Archive' },
|
||||||
{ value: 'category_archive', label: 'Category Archive' },
|
{ value: 'tag_archive', label: 'Tag Archive' },
|
||||||
{ value: 'tag_archive', label: 'Tag Archive' },
|
{ value: 'attribute_archive', label: 'Attribute Archive' },
|
||||||
{ value: 'attribute_archive', label: 'Attribute Archive' },
|
]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -270,23 +281,29 @@ export const createIdeasPageConfig = (
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Types' },
|
{ value: '', label: 'All Types' },
|
||||||
{ value: 'post', label: 'Post' },
|
...(handlers.contentTypeOptions && handlers.contentTypeOptions.length > 0
|
||||||
{ value: 'page', label: 'Page' },
|
? handlers.contentTypeOptions
|
||||||
{ value: 'product', label: 'Product' },
|
: [
|
||||||
{ value: 'taxonomy', label: 'Taxonomy' },
|
{ value: 'post', label: 'Post' },
|
||||||
|
{ value: 'page', label: 'Page' },
|
||||||
|
{ value: 'product', label: 'Product' },
|
||||||
|
{ value: 'taxonomy', label: 'Taxonomy' },
|
||||||
|
]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'keyword_cluster_id',
|
key: 'keyword_cluster_id',
|
||||||
label: 'Cluster',
|
label: 'Cluster',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: (() => {
|
|
||||||
return [
|
|
||||||
{ value: '', label: 'All Clusters' },
|
|
||||||
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
|
|
||||||
];
|
|
||||||
})(),
|
|
||||||
dynamicOptions: 'clusters',
|
dynamicOptions: 'clusters',
|
||||||
|
options: [
|
||||||
|
{ value: '', label: 'All Clusters' },
|
||||||
|
...(handlers.clusterOptions && handlers.clusterOptions.length > 0
|
||||||
|
? handlers.clusterOptions
|
||||||
|
: handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name }))
|
||||||
|
),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
formFields: (clusters: Array<{ id: number; name: string }>) => [
|
formFields: (clusters: Array<{ id: number; name: string }>) => [
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export const createKeywordsPageConfig = (
|
|||||||
countryOptions?: Array<{ value: string; label: string }>;
|
countryOptions?: Array<{ value: string; label: string }>;
|
||||||
statusOptions?: Array<{ value: string; label: string }>;
|
statusOptions?: Array<{ value: string; label: string }>;
|
||||||
clusterOptions?: Array<{ value: string; label: string }>;
|
clusterOptions?: Array<{ value: string; label: string }>;
|
||||||
|
difficultyOptions?: Array<{ value: string; label: string }>;
|
||||||
}
|
}
|
||||||
): KeywordsPageConfig => {
|
): KeywordsPageConfig => {
|
||||||
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
|
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
|
||||||
@@ -308,11 +309,16 @@ export const createKeywordsPageConfig = (
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Difficulty' },
|
{ value: '', label: 'All Difficulty' },
|
||||||
{ value: '1', label: '1 - Very Easy' },
|
...(handlers.difficultyOptions && handlers.difficultyOptions.length > 0
|
||||||
{ value: '2', label: '2 - Easy' },
|
? handlers.difficultyOptions
|
||||||
{ value: '3', label: '3 - Medium' },
|
: [
|
||||||
{ value: '4', label: '4 - Hard' },
|
{ value: '1', label: '1 - Very Easy' },
|
||||||
{ value: '5', label: '5 - Very Hard' },
|
{ value: '2', label: '2 - Easy' },
|
||||||
|
{ value: '3', label: '3 - Medium' },
|
||||||
|
{ value: '4', label: '4 - Hard' },
|
||||||
|
{ value: '5', label: '5 - Very Hard' },
|
||||||
|
]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -448,19 +454,6 @@ export const createKeywordsPageConfig = (
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'cluster_id',
|
|
||||||
label: 'Cluster',
|
|
||||||
type: 'select',
|
|
||||||
options: (() => {
|
|
||||||
// Dynamically generate options from current clusters
|
|
||||||
return [
|
|
||||||
{ value: '', label: 'All Clusters' },
|
|
||||||
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
|
|
||||||
];
|
|
||||||
})(),
|
|
||||||
className: 'w-40',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
headerMetrics: [
|
headerMetrics: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,87 @@
|
|||||||
/**
|
/**
|
||||||
* Structure mapping configuration
|
* Structure mapping configuration
|
||||||
* Maps content types to their valid structures and provides label mappings
|
* Maps content types to their valid structures and provides label mappings
|
||||||
|
* Also contains shared filter options for reuse across pages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SHARED FILTER OPTIONS - Use these in page configs to avoid duplication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Status options for Keywords page */
|
||||||
|
export const KEYWORD_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Status' },
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'mapped', label: 'Mapped' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Status options for Clusters page */
|
||||||
|
export const CLUSTER_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Status' },
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'mapped', label: 'Mapped' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Status options for Ideas page */
|
||||||
|
export const IDEAS_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Status' },
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'queued', label: 'Queued' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Status options for Content/Review/Approved pages */
|
||||||
|
export const CONTENT_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Status' },
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'review', label: 'Review' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
|
{ value: 'published', label: 'Published' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Site status options for content publishing */
|
||||||
|
export const SITE_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Site Status' },
|
||||||
|
{ value: 'not_published', label: 'Not Published' },
|
||||||
|
{ value: 'scheduled', label: 'Scheduled' },
|
||||||
|
{ value: 'publishing', label: 'Publishing' },
|
||||||
|
{ value: 'published', label: 'Published' },
|
||||||
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Source options for content */
|
||||||
|
export const SOURCE_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Sources' },
|
||||||
|
{ value: 'igny8', label: 'IGNY8' },
|
||||||
|
{ value: 'wordpress', label: 'WordPress' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Country options - used for keywords */
|
||||||
|
export const COUNTRY_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Countries' },
|
||||||
|
{ value: 'US', label: 'United States' },
|
||||||
|
{ value: 'CA', label: 'Canada' },
|
||||||
|
{ value: 'GB', label: 'United Kingdom' },
|
||||||
|
{ value: 'AE', label: 'United Arab Emirates' },
|
||||||
|
{ value: 'AU', label: 'Australia' },
|
||||||
|
{ value: 'IN', label: 'India' },
|
||||||
|
{ value: 'PK', label: 'Pakistan' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Difficulty options (1-5 scale) */
|
||||||
|
export const DIFFICULTY_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Difficulty' },
|
||||||
|
{ value: '1', label: '1 - Very Easy' },
|
||||||
|
{ value: '2', label: '2 - Easy' },
|
||||||
|
{ value: '3', label: '3 - Medium' },
|
||||||
|
{ value: '4', label: '4 - Hard' },
|
||||||
|
{ value: '5', label: '5 - Very Hard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTENT TYPE AND STRUCTURE OPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export const CONTENT_TYPE_OPTIONS = [
|
export const CONTENT_TYPE_OPTIONS = [
|
||||||
{ value: 'post', label: 'Post' },
|
{ value: 'post', label: 'Post' },
|
||||||
{ value: 'page', label: 'Page' },
|
{ value: 'page', label: 'Page' },
|
||||||
@@ -10,6 +89,35 @@ export const CONTENT_TYPE_OPTIONS = [
|
|||||||
{ value: 'taxonomy', label: 'Taxonomy' },
|
{ value: 'taxonomy', label: 'Taxonomy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Content type filter options (with "All" option) */
|
||||||
|
export const CONTENT_TYPE_FILTER_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Types' },
|
||||||
|
...CONTENT_TYPE_OPTIONS,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Content structure filter options (with "All" option) */
|
||||||
|
export const CONTENT_STRUCTURE_FILTER_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Structures' },
|
||||||
|
// Post structures
|
||||||
|
{ value: 'article', label: 'Article' },
|
||||||
|
{ value: 'guide', label: 'Guide' },
|
||||||
|
{ value: 'comparison', label: 'Comparison' },
|
||||||
|
{ value: 'review', label: 'Review' },
|
||||||
|
{ value: 'listicle', label: 'Listicle' },
|
||||||
|
// Page structures
|
||||||
|
{ value: 'landing_page', label: 'Landing Page' },
|
||||||
|
{ value: 'business_page', label: 'Business Page' },
|
||||||
|
{ value: 'service_page', label: 'Service Page' },
|
||||||
|
{ value: 'general', label: 'General' },
|
||||||
|
{ value: 'cluster_hub', label: 'Cluster Hub' },
|
||||||
|
// Product structures
|
||||||
|
{ value: 'product_page', label: 'Product Page' },
|
||||||
|
// Taxonomy structures
|
||||||
|
{ value: 'category_archive', label: 'Category Archive' },
|
||||||
|
{ value: 'tag_archive', label: 'Tag Archive' },
|
||||||
|
{ value: 'attribute_archive', label: 'Attribute Archive' },
|
||||||
|
];
|
||||||
|
|
||||||
export const CONTENT_STRUCTURE_BY_TYPE: Record<string, Array<{ value: string; label: string }>> = {
|
export const CONTENT_STRUCTURE_BY_TYPE: Record<string, Array<{ value: string; label: string }>> = {
|
||||||
post: [
|
post: [
|
||||||
{ value: 'article', label: 'Article' },
|
{ value: 'article', label: 'Article' },
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
ContentIdeaCreateData,
|
ContentIdeaCreateData,
|
||||||
fetchClusters,
|
fetchClusters,
|
||||||
Cluster,
|
Cluster,
|
||||||
|
fetchPlannerIdeasFilterOptions,
|
||||||
|
FilterOption,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import FormModal from '../../components/common/FormModal';
|
import FormModal from '../../components/common/FormModal';
|
||||||
import ProgressModal from '../../components/common/ProgressModal';
|
import ProgressModal from '../../components/common/ProgressModal';
|
||||||
@@ -49,6 +51,12 @@ export default function Ideas() {
|
|||||||
const [totalPending, setTotalPending] = useState(0);
|
const [totalPending, setTotalPending] = useState(0);
|
||||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
|
// Dynamic filter options
|
||||||
|
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
|
||||||
|
const [contentTypeOptions, setContentTypeOptions] = useState<FilterOption[]>([]);
|
||||||
|
const [contentStructureOptions, setContentStructureOptions] = useState<FilterOption[]>([]);
|
||||||
|
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
@@ -85,7 +93,7 @@ export default function Ideas() {
|
|||||||
// Progress modal for AI functions
|
// Progress modal for AI functions
|
||||||
const progressModal = useProgressModal();
|
const progressModal = useProgressModal();
|
||||||
|
|
||||||
// Load clusters for filter dropdown
|
// Load clusters for form dropdown (all clusters)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadClusters = async () => {
|
const loadClusters = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -98,6 +106,26 @@ export default function Ideas() {
|
|||||||
loadClusters();
|
loadClusters();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load dynamic filter options based on current site's data
|
||||||
|
const loadFilterOptions = useCallback(async () => {
|
||||||
|
if (!activeSite) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await fetchPlannerIdeasFilterOptions(activeSite.id);
|
||||||
|
setStatusOptions(options.statuses || []);
|
||||||
|
setContentTypeOptions(options.content_types || []);
|
||||||
|
setContentStructureOptions(options.content_structures || []);
|
||||||
|
setClusterOptions(options.clusters || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filter options:', error);
|
||||||
|
}
|
||||||
|
}, [activeSite]);
|
||||||
|
|
||||||
|
// Load filter options when site changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadFilterOptions();
|
||||||
|
}, [loadFilterOptions]);
|
||||||
|
|
||||||
// Load total metrics for footer widget (site-wide totals, no sector filter)
|
// Load total metrics for footer widget (site-wide totals, no sector filter)
|
||||||
const loadTotalMetrics = useCallback(async () => {
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -302,8 +330,13 @@ export default function Ideas() {
|
|||||||
typeFilter,
|
typeFilter,
|
||||||
setTypeFilter,
|
setTypeFilter,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
// Dynamic filter options
|
||||||
|
statusOptions,
|
||||||
|
contentTypeOptions,
|
||||||
|
contentStructureOptions,
|
||||||
|
clusterOptions,
|
||||||
});
|
});
|
||||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, statusOptions, contentTypeOptions, contentStructureOptions, clusterOptions]);
|
||||||
|
|
||||||
// Calculate header metrics - use totalInTasks/totalPending from API calls (not page data)
|
// Calculate header metrics - use totalInTasks/totalPending from API calls (not page data)
|
||||||
// This ensures metrics show correct totals across all pages, not just current page
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function Keywords() {
|
|||||||
const [countryOptions, setCountryOptions] = useState<FilterOption[]>([]);
|
const [countryOptions, setCountryOptions] = useState<FilterOption[]>([]);
|
||||||
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
|
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
|
||||||
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
|
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
|
||||||
|
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[]>([]);
|
||||||
|
|
||||||
// Filter state - match Keywords.tsx
|
// Filter state - match Keywords.tsx
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -123,24 +124,62 @@ export default function Keywords() {
|
|||||||
loadClusters();
|
loadClusters();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load dynamic filter options based on current site's data
|
// Load dynamic filter options based on current site's data and applied filters
|
||||||
const loadFilterOptions = useCallback(async () => {
|
// This implements cascading filters - each filter's options reflect what's available
|
||||||
|
// given the other currently applied filters
|
||||||
|
const loadFilterOptions = useCallback(async (currentFilters?: {
|
||||||
|
status?: string;
|
||||||
|
country?: string;
|
||||||
|
cluster_id?: string;
|
||||||
|
difficulty_min?: number;
|
||||||
|
difficulty_max?: number;
|
||||||
|
}) => {
|
||||||
if (!activeSite) return;
|
if (!activeSite) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options = await fetchPlannerKeywordFilterOptions(activeSite.id);
|
const options = await fetchPlannerKeywordFilterOptions(activeSite.id, currentFilters);
|
||||||
setCountryOptions(options.countries || []);
|
setCountryOptions(options.countries || []);
|
||||||
setStatusOptions(options.statuses || []);
|
setStatusOptions(options.statuses || []);
|
||||||
setClusterOptions(options.clusters || []);
|
setClusterOptions(options.clusters || []);
|
||||||
|
setDifficultyOptions(options.difficulties || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading filter options:', error);
|
console.error('Error loading filter options:', error);
|
||||||
}
|
}
|
||||||
}, [activeSite]);
|
}, [activeSite]);
|
||||||
|
|
||||||
// Load filter options when site changes
|
// Parse difficulty filter to min/max values
|
||||||
|
// Backend uses: 1=0-10, 2=11-30, 3=31-50, 4=51-70, 5=71-100
|
||||||
|
const getDifficultyRange = useCallback((filter: string): { min?: number; max?: number } => {
|
||||||
|
if (!filter) return {};
|
||||||
|
const level = parseInt(filter, 10);
|
||||||
|
if (isNaN(level)) return {};
|
||||||
|
// Map difficulty level to raw difficulty range matching backend logic
|
||||||
|
const ranges: Record<number, { min: number; max: number }> = {
|
||||||
|
1: { min: 0, max: 10 },
|
||||||
|
2: { min: 11, max: 30 },
|
||||||
|
3: { min: 31, max: 50 },
|
||||||
|
4: { min: 51, max: 70 },
|
||||||
|
5: { min: 71, max: 100 },
|
||||||
|
};
|
||||||
|
return ranges[level] || {};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load filter options when site changes (initial load with no filters)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFilterOptions();
|
loadFilterOptions();
|
||||||
}, [loadFilterOptions]);
|
}, [activeSite]);
|
||||||
|
|
||||||
|
// Reload filter options when any filter changes (cascading filters)
|
||||||
|
useEffect(() => {
|
||||||
|
const { min: difficultyMin, max: difficultyMax } = getDifficultyRange(difficultyFilter);
|
||||||
|
loadFilterOptions({
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
country: countryFilter || undefined,
|
||||||
|
cluster_id: clusterFilter || undefined,
|
||||||
|
difficulty_min: difficultyMin,
|
||||||
|
difficulty_max: difficultyMax,
|
||||||
|
});
|
||||||
|
}, [statusFilter, countryFilter, clusterFilter, difficultyFilter, loadFilterOptions, getDifficultyRange]);
|
||||||
|
|
||||||
// Load total metrics for footer widget (site-wide totals, no sector filter)
|
// Load total metrics for footer widget (site-wide totals, no sector filter)
|
||||||
const loadTotalMetrics = useCallback(async () => {
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
@@ -553,6 +592,7 @@ export default function Keywords() {
|
|||||||
countryOptions,
|
countryOptions,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
clusterOptions,
|
clusterOptions,
|
||||||
|
difficultyOptions,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
clusters,
|
clusters,
|
||||||
@@ -573,6 +613,7 @@ export default function Keywords() {
|
|||||||
countryOptions,
|
countryOptions,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
clusterOptions,
|
clusterOptions,
|
||||||
|
difficultyOptions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data)
|
// Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data)
|
||||||
@@ -715,7 +756,7 @@ export default function Keywords() {
|
|||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
country: countryFilter,
|
country: countryFilter,
|
||||||
difficulty: difficultyFilter,
|
difficulty: difficultyFilter,
|
||||||
cluster_id: clusterFilter,
|
cluster: clusterFilter,
|
||||||
volumeMin: volumeMin,
|
volumeMin: volumeMin,
|
||||||
volumeMax: volumeMax,
|
volumeMax: volumeMax,
|
||||||
}}
|
}}
|
||||||
@@ -741,7 +782,7 @@ export default function Keywords() {
|
|||||||
} else if (key === 'difficulty') {
|
} else if (key === 'difficulty') {
|
||||||
setDifficultyFilter(stringValue);
|
setDifficultyFilter(stringValue);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
} else if (key === 'cluster_id') {
|
} else if (key === 'cluster') {
|
||||||
setClusterFilter(stringValue);
|
setClusterFilter(stringValue);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -751,11 +751,70 @@ export interface PlannerKeywordFilterOptions {
|
|||||||
countries: FilterOption[];
|
countries: FilterOption[];
|
||||||
statuses: FilterOption[];
|
statuses: FilterOption[];
|
||||||
clusters: FilterOption[];
|
clusters: FilterOption[];
|
||||||
|
difficulties: FilterOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPlannerKeywordFilterOptions(siteId?: number): Promise<PlannerKeywordFilterOptions> {
|
// Filter options request with current filter state for cascading
|
||||||
|
export interface KeywordFilterOptionsRequest {
|
||||||
|
site_id?: number;
|
||||||
|
status?: string;
|
||||||
|
country?: string;
|
||||||
|
difficulty_min?: number;
|
||||||
|
difficulty_max?: number;
|
||||||
|
cluster_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlannerKeywordFilterOptions(
|
||||||
|
siteId?: number,
|
||||||
|
filters?: KeywordFilterOptionsRequest
|
||||||
|
): Promise<PlannerKeywordFilterOptions> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (siteId) params.append('site_id', siteId.toString());
|
||||||
|
if (filters?.status) params.append('status', filters.status);
|
||||||
|
if (filters?.country) params.append('country', filters.country);
|
||||||
|
if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
|
||||||
|
if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
|
||||||
|
if (filters?.cluster_id) params.append('cluster_id', filters.cluster_id);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return fetchAPI(`/v1/planner/keywords/filter_options/${queryString ? `?${queryString}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clusters filter options
|
||||||
|
export interface PlannerClusterFilterOptions {
|
||||||
|
statuses: FilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlannerClusterFilterOptions(siteId?: number): Promise<PlannerClusterFilterOptions> {
|
||||||
const queryParams = siteId ? `?site_id=${siteId}` : '';
|
const queryParams = siteId ? `?site_id=${siteId}` : '';
|
||||||
return fetchAPI(`/v1/planner/keywords/filter_options/${queryParams}`);
|
return fetchAPI(`/v1/planner/clusters/filter_options/${queryParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ideas filter options
|
||||||
|
export interface PlannerIdeasFilterOptions {
|
||||||
|
statuses: FilterOption[];
|
||||||
|
content_types: FilterOption[];
|
||||||
|
content_structures: FilterOption[];
|
||||||
|
clusters: FilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlannerIdeasFilterOptions(siteId?: number): Promise<PlannerIdeasFilterOptions> {
|
||||||
|
const queryParams = siteId ? `?site_id=${siteId}` : '';
|
||||||
|
return fetchAPI(`/v1/planner/ideas/filter_options/${queryParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content filter options (Writer module)
|
||||||
|
export interface WriterContentFilterOptions {
|
||||||
|
statuses: FilterOption[];
|
||||||
|
site_statuses: FilterOption[];
|
||||||
|
content_types: FilterOption[];
|
||||||
|
content_structures: FilterOption[];
|
||||||
|
sources: FilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWriterContentFilterOptions(siteId?: number): Promise<WriterContentFilterOptions> {
|
||||||
|
const queryParams = siteId ? `?site_id=${siteId}` : '';
|
||||||
|
return fetchAPI(`/v1/writer/content/filter_options/${queryParams}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clusters-specific API functions
|
// Clusters-specific API functions
|
||||||
|
|||||||
@@ -517,18 +517,23 @@
|
|||||||
SECTION 6: FORM ELEMENT STYLES
|
SECTION 6: FORM ELEMENT STYLES
|
||||||
=================================================================== */
|
=================================================================== */
|
||||||
|
|
||||||
/* Styled Select/Dropdown with custom chevron */
|
/* Styled Select/Dropdown with custom chevron - for native <select> elements */
|
||||||
.igny8-select-styled {
|
select.igny8-select-styled {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
||||||
background-repeat: no-repeat !important;
|
background-repeat: no-repeat !important;
|
||||||
background-position: right 12px center !important;
|
background-position: right 12px center !important;
|
||||||
padding-right: 36px !important;
|
padding-right: 36px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .igny8-select-styled {
|
.dark select.igny8-select-styled {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* For button-based selects (SelectDropdown), icon is rendered by component */
|
||||||
|
button.igny8-select-styled {
|
||||||
|
/* No background-image - ChevronDownIcon is rendered inside the component */
|
||||||
|
}
|
||||||
|
|
||||||
/* Checkbox styling */
|
/* Checkbox styling */
|
||||||
.tableCheckbox:checked ~ span span { @apply opacity-100; }
|
.tableCheckbox:checked ~ span span { @apply opacity-100; }
|
||||||
.tableCheckbox:checked ~ span { @apply border-brand-500 bg-brand-500; }
|
.tableCheckbox:checked ~ span { @apply border-brand-500 bg-brand-500; }
|
||||||
|
|||||||
@@ -736,8 +736,8 @@ export default function TablePageTemplate({
|
|||||||
{/* Filters Row - Below action buttons, left aligned with shadow */}
|
{/* Filters Row - Below action buttons, left aligned with shadow */}
|
||||||
{showFilters && (renderFilters || filters.length > 0) && (
|
{showFilters && (renderFilters || filters.length > 0) && (
|
||||||
<div className="flex justify-start py-1.5 mb-2.5">
|
<div className="flex justify-start py-1.5 mb-2.5">
|
||||||
<div className="bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md">
|
<div className="inline-flex bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md">
|
||||||
<div className="flex gap-3 items-center flex-wrap">
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
{renderFilters ? (
|
{renderFilters ? (
|
||||||
renderFilters
|
renderFilters
|
||||||
) : (
|
) : (
|
||||||
@@ -757,7 +757,7 @@ export default function TablePageTemplate({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onFilterChange?.(filter.key, e.target.value);
|
onFilterChange?.(filter.key, e.target.value);
|
||||||
}}
|
}}
|
||||||
className="w-full sm:flex-1 h-8"
|
className="w-48 h-8"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (filter.type === 'select') {
|
} else if (filter.type === 'select') {
|
||||||
@@ -772,7 +772,6 @@ export default function TablePageTemplate({
|
|||||||
const newValue = value === null || value === undefined ? '' : String(value);
|
const newValue = value === null || value === undefined ? '' : String(value);
|
||||||
onFilterChange?.(filter.key, newValue);
|
onFilterChange?.(filter.key, newValue);
|
||||||
}}
|
}}
|
||||||
className={filter.className || "w-full sm:flex-1"}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user