diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index 4275f5b2..3943083d 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -776,9 +776,12 @@ class KeywordViewSet(SiteSectorModelViewSet): cluster_filter = request.query_params.get('cluster_id') difficulty_min = request.query_params.get('difficulty_min') difficulty_max = request.query_params.get('difficulty_max') + volume_min = request.query_params.get('volume_min') + volume_max = request.query_params.get('volume_max') + search_term = request.query_params.get('search') # Base queryset for each filter option calculation - # For countries: apply status, cluster, difficulty filters + # For countries: apply status, cluster, difficulty, volume, search filters countries_qs = queryset if status_filter: countries_qs = countries_qs.filter(status=status_filter) @@ -800,8 +803,23 @@ class KeywordViewSet(SiteSectorModelViewSet): ) except (ValueError, TypeError): pass + if volume_min is not None: + try: + countries_qs = countries_qs.filter(seed_keyword__volume__gte=int(volume_min)) + except (ValueError, TypeError): + pass + if volume_max is not None: + try: + countries_qs = countries_qs.filter(seed_keyword__volume__lte=int(volume_max)) + except (ValueError, TypeError): + pass + if search_term: + countries_qs = countries_qs.filter( + Q(seed_keyword__keyword__icontains=search_term) | + Q(cluster__name__icontains=search_term) + ) - # For statuses: apply country, cluster, difficulty filters + # For statuses: apply country, cluster, difficulty, volume, search filters statuses_qs = queryset if country_filter: statuses_qs = statuses_qs.filter(seed_keyword__country=country_filter) @@ -823,8 +841,23 @@ class KeywordViewSet(SiteSectorModelViewSet): ) except (ValueError, TypeError): pass + if volume_min is not None: + try: + statuses_qs = statuses_qs.filter(seed_keyword__volume__gte=int(volume_min)) + except (ValueError, TypeError): + pass + if volume_max is not None: + try: + statuses_qs = statuses_qs.filter(seed_keyword__volume__lte=int(volume_max)) + except (ValueError, TypeError): + pass + if search_term: + statuses_qs = statuses_qs.filter( + Q(seed_keyword__keyword__icontains=search_term) | + Q(cluster__name__icontains=search_term) + ) - # For clusters: apply status, country, difficulty filters + # For clusters: apply status, country, difficulty, volume, search filters clusters_qs = queryset if status_filter: clusters_qs = clusters_qs.filter(status=status_filter) @@ -846,8 +879,23 @@ class KeywordViewSet(SiteSectorModelViewSet): ) except (ValueError, TypeError): pass + if volume_min is not None: + try: + clusters_qs = clusters_qs.filter(seed_keyword__volume__gte=int(volume_min)) + except (ValueError, TypeError): + pass + if volume_max is not None: + try: + clusters_qs = clusters_qs.filter(seed_keyword__volume__lte=int(volume_max)) + except (ValueError, TypeError): + pass + if search_term: + clusters_qs = clusters_qs.filter( + Q(seed_keyword__keyword__icontains=search_term) | + Q(cluster__name__icontains=search_term) + ) - # For difficulties: apply status, country, cluster filters + # For difficulties: apply status, country, cluster, volume, search filters difficulties_qs = queryset if status_filter: difficulties_qs = difficulties_qs.filter(status=status_filter) @@ -855,6 +903,21 @@ class KeywordViewSet(SiteSectorModelViewSet): difficulties_qs = difficulties_qs.filter(seed_keyword__country=country_filter) if cluster_filter: difficulties_qs = difficulties_qs.filter(cluster_id=cluster_filter) + if volume_min is not None: + try: + difficulties_qs = difficulties_qs.filter(seed_keyword__volume__gte=int(volume_min)) + except (ValueError, TypeError): + pass + if volume_max is not None: + try: + difficulties_qs = difficulties_qs.filter(seed_keyword__volume__lte=int(volume_max)) + except (ValueError, TypeError): + pass + if search_term: + difficulties_qs = difficulties_qs.filter( + Q(seed_keyword__keyword__icontains=search_term) | + Q(cluster__name__icontains=search_term) + ) # Get distinct countries countries = list(set(countries_qs.values_list('seed_keyword__country', flat=True))) @@ -1259,18 +1322,56 @@ class ClusterViewSet(SiteSectorModelViewSet): @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. + Get distinct filter values from current data with cascading filter support. + Returns only statuses and difficulties that exist based on other active filters. """ import logging + from django.db.models import Q, Sum, Avg, Case, When, F, IntegerField logger = logging.getLogger(__name__) try: + # Start with base queryset (already has volume/difficulty annotations from get_queryset) queryset = self.get_queryset() + # Get filter parameters for cascading + status_filter = request.query_params.get('status', '') + difficulty_min = request.query_params.get('difficulty_min', '') + difficulty_max = request.query_params.get('difficulty_max', '') + volume_min = request.query_params.get('volume_min', '') + volume_max = request.query_params.get('volume_max', '') + search = request.query_params.get('search', '') + + # ===== GET STATUS OPTIONS ===== + # Apply OTHER filters (exclude status) to get valid status options + status_qs = queryset + if difficulty_min: + try: + status_qs = status_qs.filter(_annotated_difficulty__gte=float(difficulty_min)) + except ValueError: + pass + if difficulty_max: + try: + status_qs = status_qs.filter(_annotated_difficulty__lte=float(difficulty_max)) + except ValueError: + pass + if volume_min: + try: + status_qs = status_qs.filter(_annotated_volume__gte=int(volume_min)) + except ValueError: + pass + if volume_max: + try: + status_qs = status_qs.filter(_annotated_volume__lte=int(volume_max)) + except ValueError: + pass + if search: + status_qs = status_qs.filter( + Q(name__icontains=search) | Q(description__icontains=search) + ) + # Get distinct statuses - statuses = list(set(queryset.values_list('status', flat=True))) + statuses = list(set(status_qs.values_list('status', flat=True))) statuses = sorted([s for s in statuses if s]) status_labels = { 'new': 'New', @@ -1281,9 +1382,60 @@ class ClusterViewSet(SiteSectorModelViewSet): for s in statuses ] + # ===== GET DIFFICULTY OPTIONS ===== + # Apply OTHER filters (exclude difficulty) to get valid difficulty options + difficulty_qs = queryset + if status_filter: + difficulty_qs = difficulty_qs.filter(status=status_filter) + if volume_min: + try: + difficulty_qs = difficulty_qs.filter(_annotated_volume__gte=int(volume_min)) + except ValueError: + pass + if volume_max: + try: + difficulty_qs = difficulty_qs.filter(_annotated_volume__lte=int(volume_max)) + except ValueError: + pass + if search: + difficulty_qs = difficulty_qs.filter( + Q(name__icontains=search) | Q(description__icontains=search) + ) + + # Get raw difficulty values (0-100) from annotated field + difficulty_values = difficulty_qs.exclude(_annotated_difficulty__isnull=True).values_list('_annotated_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( data={ 'statuses': status_options, + 'difficulties': difficulty_options, }, request=request ) @@ -1476,18 +1628,40 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): @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. + Get distinct filter values from current data with cascading filter support. + Returns only values that exist based on other active filters. """ import logging + from django.db.models import Q logger = logging.getLogger(__name__) try: queryset = self.get_queryset() - # Get distinct statuses - statuses = list(set(queryset.values_list('status', flat=True))) + # Get filter parameters for cascading + status_filter = request.query_params.get('status', '') + content_type_filter = request.query_params.get('content_type', '') + content_structure_filter = request.query_params.get('content_structure', '') + cluster_filter = request.query_params.get('cluster', '') + search = request.query_params.get('search', '') + + # Apply search filter to all options if provided + base_qs = queryset + if search: + base_qs = base_qs.filter( + Q(idea_title__icontains=search) | Q(description__icontains=search) + ) + + # Get statuses (filtered by type, structure, cluster) + status_qs = base_qs + if content_type_filter: + status_qs = status_qs.filter(content_type=content_type_filter) + if content_structure_filter: + status_qs = status_qs.filter(content_structure=content_structure_filter) + if cluster_filter: + status_qs = status_qs.filter(keyword_cluster_id=cluster_filter) + statuses = list(set(status_qs.values_list('status', flat=True))) statuses = sorted([s for s in statuses if s]) status_labels = { 'new': 'New', @@ -1499,8 +1673,15 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): for s in statuses ] - # Get distinct content_types - content_types = list(set(queryset.values_list('content_type', flat=True))) + # Get content_types (filtered by status, structure, cluster) + type_qs = base_qs + if status_filter: + type_qs = type_qs.filter(status=status_filter) + if content_structure_filter: + type_qs = type_qs.filter(content_structure=content_structure_filter) + if cluster_filter: + type_qs = type_qs.filter(keyword_cluster_id=cluster_filter) + content_types = list(set(type_qs.values_list('content_type', flat=True))) content_types = sorted([t for t in content_types if t]) type_labels = { 'post': 'Post', @@ -1513,8 +1694,15 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): for t in content_types ] - # Get distinct content_structures - structures = list(set(queryset.values_list('content_structure', flat=True))) + # Get content_structures (filtered by status, type, cluster) + structure_qs = base_qs + if status_filter: + structure_qs = structure_qs.filter(status=status_filter) + if content_type_filter: + structure_qs = structure_qs.filter(content_type=content_type_filter) + if cluster_filter: + structure_qs = structure_qs.filter(keyword_cluster_id=cluster_filter) + structures = list(set(structure_qs.values_list('content_structure', flat=True))) structures = sorted([s for s in structures if s]) structure_labels = { 'article': 'Article', 'guide': 'Guide', 'comparison': 'Comparison', @@ -1529,9 +1717,16 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): for s in structures ] - # Get distinct clusters with ideas + # Get distinct clusters (filtered by status, type, structure) + cluster_qs = base_qs + if status_filter: + cluster_qs = cluster_qs.filter(status=status_filter) + if content_type_filter: + cluster_qs = cluster_qs.filter(content_type=content_type_filter) + if content_structure_filter: + cluster_qs = cluster_qs.filter(content_structure=content_structure_filter) cluster_ids = list(set( - queryset.exclude(keyword_cluster_id__isnull=True) + cluster_qs.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') diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 9191fa9b..17f56bb1 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -272,6 +272,167 @@ class TasksViewSet(SiteSectorModelViewSet): status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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 with cascading support. + Returns only values that exist based on other active filters. + """ + import logging + from django.db.models import Q + + logger = logging.getLogger(__name__) + + try: + queryset = self.get_queryset() + + # Get filter parameters for cascading + status_filter = request.query_params.get('status', '') + content_type_filter = request.query_params.get('content_type', '') + content_structure_filter = request.query_params.get('content_structure', '') + cluster_filter = request.query_params.get('cluster', '') + source_filter = request.query_params.get('source', '') + search = request.query_params.get('search', '') + + # Apply search to base queryset + base_qs = queryset + if search: + base_qs = base_qs.filter( + Q(title__icontains=search) | Q(keywords__icontains=search) + ) + + # Get statuses (filtered by other fields) + status_qs = base_qs + if content_type_filter: + status_qs = status_qs.filter(content_type=content_type_filter) + if content_structure_filter: + status_qs = status_qs.filter(content_structure=content_structure_filter) + if cluster_filter: + status_qs = status_qs.filter(cluster_id=cluster_filter) + if source_filter: + status_qs = status_qs.filter(source=source_filter) + statuses = list(set(status_qs.values_list('status', flat=True))) + statuses = sorted([s for s in statuses if s]) + status_labels = { + 'queued': 'Queued', + 'processing': 'Processing', + 'completed': 'Completed', + 'failed': 'Failed', + } + status_options = [ + {'value': s, 'label': status_labels.get(s, s.title())} + for s in statuses + ] + + # Get content types (filtered by other fields) + type_qs = base_qs + if status_filter: + type_qs = type_qs.filter(status=status_filter) + if content_structure_filter: + type_qs = type_qs.filter(content_structure=content_structure_filter) + if cluster_filter: + type_qs = type_qs.filter(cluster_id=cluster_filter) + if source_filter: + type_qs = type_qs.filter(source=source_filter) + content_types = list(set(type_qs.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 content structures (filtered by other fields) + structure_qs = base_qs + if status_filter: + structure_qs = structure_qs.filter(status=status_filter) + if content_type_filter: + structure_qs = structure_qs.filter(content_type=content_type_filter) + if cluster_filter: + structure_qs = structure_qs.filter(cluster_id=cluster_filter) + if source_filter: + structure_qs = structure_qs.filter(source=source_filter) + structures = list(set(structure_qs.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 clusters (filtered by other fields) + cluster_qs = base_qs + if status_filter: + cluster_qs = cluster_qs.filter(status=status_filter) + if content_type_filter: + cluster_qs = cluster_qs.filter(content_type=content_type_filter) + if content_structure_filter: + cluster_qs = cluster_qs.filter(content_structure=content_structure_filter) + if source_filter: + cluster_qs = cluster_qs.filter(source=source_filter) + from igny8_core.modules.planner.models import Clusters + cluster_ids = list(set( + cluster_qs.exclude(cluster_id__isnull=True) + .values_list('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 + ] + + # Get sources (filtered by other fields) + source_qs = base_qs + if status_filter: + source_qs = source_qs.filter(status=status_filter) + if content_type_filter: + source_qs = source_qs.filter(content_type=content_type_filter) + if content_structure_filter: + source_qs = source_qs.filter(content_structure=content_structure_filter) + if cluster_filter: + source_qs = source_qs.filter(cluster_id=cluster_filter) + sources = list(set(source_qs.values_list('source', flat=True))) + sources = sorted([s for s in sources if s]) + source_labels = { + 'manual': 'Manual', + 'planner': 'Planner', + 'ai': 'AI', + } + source_options = [ + {'value': s, 'label': source_labels.get(s, s.title())} + for s in sources + ] + + return success_response( + data={ + 'statuses': status_options, + 'content_types': content_type_options, + 'content_structures': content_structure_options, + 'clusters': cluster_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 + ) @extend_schema_view( diff --git a/frontend/src/components/form/SelectDropdown.tsx b/frontend/src/components/form/SelectDropdown.tsx index 9ec11dec..2462373c 100644 --- a/frontend/src/components/form/SelectDropdown.tsx +++ b/frontend/src/components/form/SelectDropdown.tsx @@ -50,6 +50,17 @@ const SelectDropdown: React.FC = ({ const displayText = selectedOption ? selectedOption.label : placeholder; const isPlaceholder = !selectedOption; + // Calculate minimum width based on longest option text + // This ensures dropdown is wide enough for all options from the start + const getLongestOptionLength = () => { + if (options.length === 0) return placeholder.length; + const allTexts = [placeholder, ...options.map(opt => opt.label)]; + return Math.max(...allTexts.map(text => text.length)); + }; + + // Estimate width: ~8px per character + padding (this is approximate) + const estimatedMinWidth = Math.min(360, Math.max(120, getLongestOptionLength() * 8 + 40)); + // Handle click outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -102,7 +113,8 @@ const SelectDropdown: React.FC = ({ onClick={() => !disabled && setIsOpen(!isOpen)} disabled={disabled} onKeyDown={handleKeyDown} - 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 ${ + style={{ minWidth: `${estimatedMinWidth}px` }} + className={`igny8-select-styled w-auto 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' } ${ isPlaceholder diff --git a/frontend/src/config/pages/clusters.config.tsx b/frontend/src/config/pages/clusters.config.tsx index 4ac0e028..c626d36b 100644 --- a/frontend/src/config/pages/clusters.config.tsx +++ b/frontend/src/config/pages/clusters.config.tsx @@ -282,8 +282,15 @@ export const createClustersPageConfig = ( type: 'select', options: [ { value: '', label: 'All Status' }, - { value: 'new', label: 'New' }, - { value: 'mapped', label: 'Mapped' }, + // Use dynamic options if loaded (even if empty array) + // Only fall back to defaults if statusOptions is undefined (not loaded yet) + ...(handlers.statusOptions !== undefined + ? handlers.statusOptions + : [ + { value: 'new', label: 'New' }, + { value: 'mapped', label: 'Mapped' }, + ] + ), ], }, { @@ -292,11 +299,18 @@ export const createClustersPageConfig = ( type: 'select', 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' }, + // Use dynamic options if loaded (even if empty array) + // Only fall back to defaults if difficultyOptions is undefined (not loaded yet) + ...(handlers.difficultyOptions !== undefined + ? handlers.difficultyOptions + : [ + { 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' }, + ] + ), ], }, { @@ -304,7 +318,7 @@ export const createClustersPageConfig = ( label: 'Volume Range', type: 'custom', customRender: () => ( -
+
)} - {/* Filter Toggle Button */} + {/* Filter Toggle Button - with active filter count badge */} {(renderFilters || filters.length > 0) && ( - +
+ + {hasActiveFilters && ( + <> +
+
+ + {Object.values(filterValues).filter(value => { + if (value === '' || value === null || value === undefined) return false; + if (typeof value === 'object' && ('min' in value || 'max' in value)) { + return value.min !== '' && value.min !== null && value.min !== undefined || + value.max !== '' && value.max !== null && value.max !== undefined; + } + return true; + }).length} active + +
+ {onFilterReset && ( + + )} + + )} +
)}
@@ -777,15 +807,6 @@ export default function TablePageTemplate({ } return null; })} - {hasActiveFilters && onFilterReset && ( - - )} )}