diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index 671435ca..4275f5b2 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -757,20 +757,109 @@ class KeywordViewSet(SiteSectorModelViewSet): def filter_options(self, request): """ 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 + from django.db.models import Q + from django.db.models.functions import Coalesce logger = logging.getLogger(__name__) try: queryset = self.get_queryset() - # Get distinct countries from seed_keyword (use set for proper deduplication) - countries = list(set(queryset.values_list('seed_keyword__country', flat=True))) - countries = sorted([c for c in countries if c]) # Sort and filter nulls + # Apply current filters to get cascading options + # Each filter's options are based on data that matches OTHER filters + 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 country_choices = dict(SeedKeyword.COUNTRY_CHOICES) country_options = [ @@ -778,9 +867,9 @@ class KeywordViewSet(SiteSectorModelViewSet): for c in countries ] - # Get distinct statuses (use set for proper deduplication) - statuses = list(set(queryset.values_list('status', flat=True))) - statuses = sorted([s for s in statuses if s]) # Sort and filter nulls + # Get distinct statuses from filtered queryset + statuses = list(set(statuses_qs.values_list('status', flat=True))) + statuses = sorted([s for s in statuses if s]) status_labels = { 'new': 'New', 'mapped': 'Mapped', @@ -790,9 +879,9 @@ class KeywordViewSet(SiteSectorModelViewSet): for s in statuses ] - # Get distinct clusters (use set for proper deduplication) + # Get distinct clusters from filtered queryset cluster_ids = list(set( - queryset.exclude(cluster_id__isnull=True) + clusters_qs.exclude(cluster_id__isnull=True) .values_list('cluster_id', flat=True) )) @@ -802,11 +891,48 @@ class KeywordViewSet(SiteSectorModelViewSet): 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( data={ 'countries': country_options, 'statuses': status_options, 'clusters': cluster_options, + 'difficulties': difficulty_options, }, request=request ) @@ -1130,6 +1256,45 @@ class ClusterViewSet(SiteSectorModelViewSet): 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') def auto_generate_ideas(self, request): """Auto-generate ideas for clusters using IdeasService""" @@ -1308,6 +1473,90 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): 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') def bulk_delete(self, request): """Bulk delete content ideas""" diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index bdd46d7c..9191fa9b 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -824,6 +824,108 @@ class ContentViewSet(SiteSectorModelViewSet): else: 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') def bulk_delete(self, request): """Bulk delete content""" diff --git a/docs/30-FRONTEND/FILTERS-IMPLEMENTATION.md b/docs/30-FRONTEND/FILTERS-IMPLEMENTATION.md new file mode 100644 index 00000000..9a7021be --- /dev/null +++ b/docs/30-FRONTEND/FILTERS-IMPLEMENTATION.md @@ -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` | `` | Text search input | +| `select` | `` | 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 ; + } + if (filter.type === 'select') { + return ; + } + 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` | diff --git a/frontend/src/components/form/SelectDropdown.tsx b/frontend/src/components/form/SelectDropdown.tsx index 3b2bdcd0..9ec11dec 100644 --- a/frontend/src/components/form/SelectDropdown.tsx +++ b/frontend/src/components/form/SelectDropdown.tsx @@ -94,7 +94,7 @@ const SelectDropdown: React.FC = ({ }; return ( -
+
{/* Trigger Button - styled like igny8-select-styled */}