diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index 3943083d..7cec5759 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -46,9 +46,14 @@ class ClustersFilter(django_filters.FilterSet): class ContentIdeasFilter(django_filters.FilterSet): - """Custom filter for ContentIdeas with date range support""" + """Custom filter for ContentIdeas with date range support. + Uses CharFilter for content_type and content_structure to accept any value + (database may have legacy values not in current model choices). + """ created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte') created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte') + content_type = django_filters.CharFilter(field_name='content_type') + content_structure = django_filters.CharFilter(field_name='content_structure') class Meta: model = ContentIdeas @@ -1378,6 +1383,8 @@ class ClusterViewSet(SiteSectorModelViewSet): 'mapped': 'Mapped', } status_options = [ + {'value': '', 'label': 'All Status'}, + ] + [ {'value': s, 'label': status_labels.get(s, s.title())} for s in statuses ] @@ -1428,6 +1435,8 @@ class ClusterViewSet(SiteSectorModelViewSet): 5: '5 - Very Hard', } difficulty_options = [ + {'value': '', 'label': 'All Difficulty'}, + ] + [ {'value': str(d), 'label': difficulty_labels[d]} for d in sorted(difficulty_levels) ] @@ -1669,6 +1678,8 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): 'completed': 'Completed', } status_options = [ + {'value': '', 'label': 'All Status'}, + ] + [ {'value': s, 'label': status_labels.get(s, s.title())} for s in statuses ] @@ -1690,6 +1701,8 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): 'taxonomy': 'Taxonomy', } content_type_options = [ + {'value': '', 'label': 'All Types'}, + ] + [ {'value': t, 'label': type_labels.get(t, t.title())} for t in content_types ] @@ -1713,6 +1726,8 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): 'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive', } content_structure_options = [ + {'value': '', 'label': 'All Structures'}, + ] + [ {'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())} for s in structures ] @@ -1731,6 +1746,8 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): )) clusters = Clusters.objects.filter(id__in=cluster_ids).values('id', 'name').order_by('name') cluster_options = [ + {'value': '', 'label': 'All Clusters'}, + ] + [ {'value': str(c['id']), 'label': c['name']} for c in clusters ] diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 9b6120a2..9c082731 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -27,9 +27,14 @@ from igny8_core.business.billing.exceptions import InsufficientCreditsError # Custom FilterSets with date range filtering support class TasksFilter(django_filters.FilterSet): - """Custom filter for Tasks with date range support""" + """Custom filter for Tasks with date range support. + Uses CharFilter for content_type and content_structure to accept any value + (database may have legacy values not in current model choices). + """ created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte') created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte') + content_type = django_filters.CharFilter(field_name='content_type') + content_structure = django_filters.CharFilter(field_name='content_structure') class Meta: model = Tasks @@ -47,9 +52,14 @@ class ImagesFilter(django_filters.FilterSet): class ContentFilter(django_filters.FilterSet): - """Custom filter for Content with date range support""" + """Custom filter for Content with date range support. + Uses CharFilter for content_type and content_structure to accept any value + (database may have legacy values not in current model choices). + """ created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte') created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte') + content_type = django_filters.CharFilter(field_name='content_type') + content_structure = django_filters.CharFilter(field_name='content_structure') class Meta: model = Content @@ -318,6 +328,8 @@ class TasksViewSet(SiteSectorModelViewSet): 'failed': 'Failed', } status_options = [ + {'value': '', 'label': 'All Status'}, + ] + [ {'value': s, 'label': status_labels.get(s, s.title())} for s in statuses ] @@ -339,6 +351,8 @@ class TasksViewSet(SiteSectorModelViewSet): 'taxonomy': 'Taxonomy', } content_type_options = [ + {'value': '', 'label': 'All Types'}, + ] + [ {'value': t, 'label': type_labels.get(t, t.title())} for t in content_types ] @@ -362,6 +376,8 @@ class TasksViewSet(SiteSectorModelViewSet): 'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive', } content_structure_options = [ + {'value': '', 'label': 'All Structures'}, + ] + [ {'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())} for s in structures ] @@ -381,6 +397,8 @@ class TasksViewSet(SiteSectorModelViewSet): )) clusters = Clusters.objects.filter(id__in=cluster_ids).values('id', 'name').order_by('name') cluster_options = [ + {'value': '', 'label': 'All Clusters'}, + ] + [ {'value': str(c['id']), 'label': c['name']} for c in clusters ] @@ -959,14 +977,21 @@ class ContentViewSet(SiteSectorModelViewSet): # Get filter parameters for cascading status_filter = request.query_params.get('status', '') + status_in_filter = request.query_params.get('status__in', '') # Comma-separated list of statuses site_status_filter = request.query_params.get('site_status', '') content_type_filter = request.query_params.get('content_type', '') content_structure_filter = request.query_params.get('content_structure', '') source_filter = request.query_params.get('source', '') search = request.query_params.get('search', '') - # Apply search to base queryset + # Apply base status__in filter to restrict entire result set (e.g., for Approved page) base_qs = queryset + if status_in_filter: + status_list = [s.strip() for s in status_in_filter.split(',') if s.strip()] + if status_list: + base_qs = base_qs.filter(status__in=status_list) + + # Apply search to base queryset if search: base_qs = base_qs.filter( Q(title__icontains=search) | Q(summary__icontains=search) @@ -991,6 +1016,8 @@ class ContentViewSet(SiteSectorModelViewSet): 'published': 'Published', } status_options = [ + {'value': '', 'label': 'All Content Status'}, + ] + [ {'value': s, 'label': status_labels.get(s, s.title())} for s in statuses ] @@ -1015,6 +1042,8 @@ class ContentViewSet(SiteSectorModelViewSet): 'failed': 'Failed', } site_status_options = [ + {'value': '', 'label': 'All Site Status'}, + ] + [ {'value': s, 'label': site_status_labels.get(s, s.replace('_', ' ').title())} for s in site_statuses ] @@ -1038,6 +1067,8 @@ class ContentViewSet(SiteSectorModelViewSet): 'taxonomy': 'Taxonomy', } content_type_options = [ + {'value': '', 'label': 'All Types'}, + ] + [ {'value': t, 'label': type_labels.get(t, t.title())} for t in content_types ] @@ -1063,6 +1094,8 @@ class ContentViewSet(SiteSectorModelViewSet): 'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive', } content_structure_options = [ + {'value': '', 'label': 'All Structures'}, + ] + [ {'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())} for s in structures ] @@ -1084,6 +1117,8 @@ class ContentViewSet(SiteSectorModelViewSet): 'wordpress': 'WordPress', } source_options = [ + {'value': '', 'label': 'All Sources'}, + ] + [ {'value': s, 'label': source_labels.get(s, s.title())} for s in sources ] diff --git a/docs/FILTER_GUIDELINES.md b/docs/FILTER_GUIDELINES.md new file mode 100644 index 00000000..bb153d79 --- /dev/null +++ b/docs/FILTER_GUIDELINES.md @@ -0,0 +1,168 @@ +# Filter Implementation Guidelines + +## Core Principles + +### 1. Dynamic Filter Options (Cascading Filters) +Filters should **ALWAYS** load their options from the backend based on **actual data in the current filtered result set**. + +**Why?** +- Prevents showing filter values that would return 0 results +- Ensures filters only show what exists in the current data context +- Provides better UX - users see only relevant choices + +### 2. Backend filter_options Endpoint Pattern +Each module's ViewSet should have a `filter_options` action that: +- Returns distinct values from actual data +- Prepends "All X" option for each filter type +- Supports cascading (applying other filters when calculating each option list) + +```python +@action(detail=False, methods=['get'], url_path='filter_options') +def filter_options(self, request): + """ + Returns distinct filter values from ACTUAL DATA in the queryset. + Supports cascading - each filter's options exclude itself but apply other filters. + Backend ALWAYS prepends the "All X" option. + """ + queryset = self.get_queryset() + + # Get current filter values + status_filter = request.query_params.get('status', '') + type_filter = request.query_params.get('content_type', '') + + # For each filter option, apply OTHER filters (not self) + type_qs = queryset + if status_filter: + type_qs = type_qs.filter(status=status_filter) + + content_types = list(set(type_qs.values_list('content_type', flat=True))) + content_types = sorted([t for t in content_types if t]) + + # BACKEND prepends "All" option + content_type_options = [ + {'value': '', 'label': 'All Types'}, + ] + [ + {'value': t, 'label': TYPE_LABELS.get(t, t.title())} + for t in content_types + ] + + return success_response(data={'content_types': content_type_options}) +``` + +### 3. Frontend Filter Config Pattern +Frontend configs should use dynamic options **DIRECTLY** without adding "All" prefix: + +```typescript +// CORRECT - Backend already includes "All Types" option +filters: [ + { + key: 'content_type', + label: 'Type', + type: 'select', + options: handlers.contentTypeOptions, // Use directly from backend + }, +] + +// WRONG - Don't add "All" in frontend (causes duplicates) +options: [ + { value: '', label: 'All Types' }, // DON'T DO THIS + ...(handlers.contentTypeOptions || []), +] +``` + +### 4. Page-Specific Base Constraints +Pages with fixed default filters should pass constraints when loading filter_options: + +```typescript +// APPROVED PAGE - only show filters for approved/published content +const loadFilterOptions = useCallback(async (currentFilters) => { + const options = await fetchWriterContentFilterOptions(activeSite.id, { + ...currentFilters, + status__in: 'approved,published', // Base constraint + }); + setStatusOptions(options.statuses || []); +}, [activeSite]); + +// DRAFTS PAGE - only show filters for draft content +const loadFilterOptions = useCallback(async (currentFilters) => { + const options = await fetchWriterContentFilterOptions(activeSite.id, { + ...currentFilters, + status: 'draft', // Base constraint + }); + setContentTypeOptions(options.content_types || []); +}, [activeSite]); +``` + +### 5. Clear Filters Button +Every page with filters should provide `onFilterReset` handler: + +```typescript + { /* ... */ }} + onFilterReset={() => { + setSearchTerm(''); + setContentTypeFilter(''); + setCurrentPage(1); + }} +/> +``` + +### 6. Filter Dependencies +Ensure filter state variables are in useCallback dependency arrays: + +```typescript +// CORRECT - all filter states in dependencies +const loadContent = useCallback(async () => { + const filters = { + status: statusFilter, + content_type: contentTypeFilter, + }; + // ... +}, [statusFilter, contentTypeFilter, /* other deps */]); + +// WRONG - missing filter in dependencies (filter won't trigger reload) +}, [statusFilter]); // Missing contentTypeFilter! +``` + +### 7. What NOT to Do + +❌ **Don't add "All X" prefix in frontend config (backend does it):** +```typescript +// BAD - causes duplicate "All Types" entries +options: [ + { value: '', label: 'All Types' }, + ...(handlers.contentTypeOptions || []), +] +``` + +❌ **Don't forget filter state in useCallback dependencies:** +```typescript +// BAD - contentStatusFilter changes won't reload data +}, [statusFilter, sortBy, searchTerm]); // Missing contentStatusFilter +``` + +❌ **Don't forget onFilterReset handler:** +```typescript +// BAD - no Clear button will show + +``` + +## Implementation Checklist + +For each page with filters: + +- [ ] Backend `filter_options` prepends "All X" option for each filter type +- [ ] Backend applies cascading logic (each filter excludes itself) +- [ ] Frontend config uses `handlers.xxxOptions` directly (no "All" prefix) +- [ ] Page passes base constraints (e.g., status='draft') to filter_options +- [ ] Page provides `onFilterReset` handler to TablePageTemplate +- [ ] All filter states are in useCallback/useEffect dependencies +- [ ] Filter values in filterValues object match filter keys in config diff --git a/frontend/src/config/pages/approved.config.tsx b/frontend/src/config/pages/approved.config.tsx index 605c07c5..c22e7b57 100644 --- a/frontend/src/config/pages/approved.config.tsx +++ b/frontend/src/config/pages/approved.config.tsx @@ -371,44 +371,25 @@ export function createApprovedPageConfig(params: { key: 'status', label: 'Status', type: 'select', - options: params.statusOptions || [ - { value: '', label: 'All' }, - { value: 'draft', label: 'Draft' }, - { value: 'review', label: 'Review' }, - { value: 'approved', label: 'Approved' }, - { value: 'published', label: 'Published' }, - ], + options: params.statusOptions, }, { key: 'site_status', label: 'Site Status', type: 'select', - options: params.siteStatusOptions || [ - { value: '', label: 'All' }, - { value: 'not_published', label: 'Not Published' }, - { value: 'scheduled', label: 'Scheduled' }, - { value: 'publishing', label: 'Publishing' }, - { value: 'published', label: 'Published' }, - { value: 'failed', label: 'Failed' }, - ], + options: params.siteStatusOptions, }, { key: 'content_type', label: 'Type', type: 'select', - options: params.contentTypeOptions || [ - { value: '', label: 'All Types' }, - ...CONTENT_TYPE_OPTIONS, - ], + options: params.contentTypeOptions, }, { key: 'content_structure', label: 'Structure', type: 'select', - options: params.contentStructureOptions || [ - { value: '', label: 'All Structures' }, - ...ALL_CONTENT_STRUCTURES, - ], + options: params.contentStructureOptions, }, ]; diff --git a/frontend/src/config/pages/clusters.config.tsx b/frontend/src/config/pages/clusters.config.tsx index 0305aee5..376703b0 100644 --- a/frontend/src/config/pages/clusters.config.tsx +++ b/frontend/src/config/pages/clusters.config.tsx @@ -282,38 +282,13 @@ export const createClustersPageConfig = ( key: 'status', label: 'Status', type: 'select', - options: [ - { value: '', label: 'All Status' }, - // 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' }, - ] - ), - ], + options: handlers.statusOptions, }, { key: 'difficulty', label: 'Difficulty', type: 'select', - options: [ - { value: '', label: 'All Difficulty' }, - // 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' }, - ] - ), - ], + options: handlers.difficultyOptions, }, { key: 'volume', diff --git a/frontend/src/config/pages/content.config.tsx b/frontend/src/config/pages/content.config.tsx index 421c1ee5..16674a98 100644 --- a/frontend/src/config/pages/content.config.tsx +++ b/frontend/src/config/pages/content.config.tsx @@ -387,55 +387,19 @@ export const createContentPageConfig = ( key: 'content_type', label: 'Content Type', type: 'select', - options: [ - { value: '', label: 'All Types' }, - ...(handlers.contentTypeOptions !== undefined - ? handlers.contentTypeOptions - : CONTENT_TYPE_OPTIONS - ), - ], + options: handlers.contentTypeOptions, }, { key: 'content_structure', label: 'Content Structure', type: 'select', - options: [ - { value: '', label: 'All Structures' }, - ...(handlers.contentStructureOptions !== undefined - ? handlers.contentStructureOptions - : [ - { 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' }, - ] - ), - ], + options: handlers.contentStructureOptions, }, { key: 'source', label: 'Source', type: 'select', - options: [ - { value: '', label: 'All Sources' }, - ...(handlers.sourceOptions !== undefined - ? handlers.sourceOptions - : [ - { value: 'igny8', label: 'IGNY8' }, - { value: 'wordpress', label: 'WordPress' }, - ] - ), - ], + options: handlers.sourceOptions, }, ], headerMetrics: [ diff --git a/frontend/src/config/pages/ideas.config.tsx b/frontend/src/config/pages/ideas.config.tsx index e21617d3..3e1f35d4 100644 --- a/frontend/src/config/pages/ideas.config.tsx +++ b/frontend/src/config/pages/ideas.config.tsx @@ -249,82 +249,26 @@ export const createIdeasPageConfig = ( key: 'status', label: 'Status', type: 'select', - options: [ - { value: '', label: 'All Status' }, - // 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: 'queued', label: 'Queued' }, - { value: 'completed', label: 'Completed' }, - ] - ), - ], + options: handlers.statusOptions, }, { key: 'content_structure', label: 'Structure', type: 'select', - options: [ - { value: '', label: 'All Structures' }, - // Use dynamic options if loaded (even if empty array) - // Only fall back to defaults if contentStructureOptions is undefined (not loaded yet) - ...(handlers.contentStructureOptions !== undefined - ? handlers.contentStructureOptions - : [ - { 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' }, - ] - ), - ], + options: handlers.contentStructureOptions, }, { key: 'content_type', label: 'Type', type: 'select', - options: [ - { value: '', label: 'All Types' }, - // Use dynamic options if loaded (even if empty array) - // Only fall back to defaults if contentTypeOptions is undefined (not loaded yet) - ...(handlers.contentTypeOptions !== undefined - ? handlers.contentTypeOptions - : [ - { value: 'post', label: 'Post' }, - { value: 'page', label: 'Page' }, - { value: 'product', label: 'Product' }, - { value: 'taxonomy', label: 'Taxonomy' }, - ] - ), - ], + options: handlers.contentTypeOptions, }, { key: 'keyword_cluster_id', label: 'Cluster', type: 'select', dynamicOptions: 'clusters', - options: [ - { value: '', label: 'All Clusters' }, - // Use dynamic cluster options if loaded (even if empty array) - // Only fall back to full clusters list if clusterOptions is undefined (not loaded yet) - ...(handlers.clusterOptions !== undefined - ? handlers.clusterOptions - : handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })) - ), - ], + options: handlers.clusterOptions, }, ], formFields: (clusters: Array<{ id: number; name: string }>) => [ diff --git a/frontend/src/config/pages/images.config.tsx b/frontend/src/config/pages/images.config.tsx index 1a80f4ed..7f75d9a1 100644 --- a/frontend/src/config/pages/images.config.tsx +++ b/frontend/src/config/pages/images.config.tsx @@ -197,20 +197,14 @@ export const createImagesPageConfig = ( key: 'content_status', label: 'Content Status', type: 'select', - options: handlers.contentStatusOptions || [ - { value: '', label: 'All' }, - { value: 'draft', label: 'Draft' }, - { value: 'review', label: 'Review' }, - { value: 'approved', label: 'Approved' }, - { value: 'published', label: 'Published' }, - ], + options: handlers.contentStatusOptions, }, { key: 'status', label: 'Image Status', type: 'select', options: [ - { value: '', label: 'All Status' }, + { value: '', label: 'All Image Status' }, { value: 'complete', label: 'Complete' }, { value: 'partial', label: 'Partial' }, { value: 'pending', label: 'Pending' }, diff --git a/frontend/src/config/pages/review.config.tsx b/frontend/src/config/pages/review.config.tsx index 2b66668e..27816f78 100644 --- a/frontend/src/config/pages/review.config.tsx +++ b/frontend/src/config/pages/review.config.tsx @@ -245,32 +245,19 @@ export function createReviewPageConfig(params: { key: 'site_status', label: 'Site Status', type: 'select', - options: params.siteStatusOptions || [ - { value: '', label: 'All' }, - { value: 'not_published', label: 'Not Published' }, - { value: 'scheduled', label: 'Scheduled' }, - { value: 'publishing', label: 'Publishing' }, - { value: 'published', label: 'Published' }, - { value: 'failed', label: 'Failed' }, - ], + options: params.siteStatusOptions, }, { key: 'content_type', label: 'Type', type: 'select', - options: params.contentTypeOptions || [ - { value: '', label: 'All Types' }, - ...CONTENT_TYPE_OPTIONS, - ], + options: params.contentTypeOptions, }, { key: 'content_structure', label: 'Structure', type: 'select', - options: params.contentStructureOptions || [ - { value: '', label: 'All Structures' }, - ...ALL_CONTENT_STRUCTURES, - ], + options: params.contentStructureOptions, }, ], headerMetrics: [ diff --git a/frontend/src/config/pages/tasks.config.tsx b/frontend/src/config/pages/tasks.config.tsx index dbf0ec4b..728b76da 100644 --- a/frontend/src/config/pages/tasks.config.tsx +++ b/frontend/src/config/pages/tasks.config.tsx @@ -317,69 +317,25 @@ export const createTasksPageConfig = ( key: 'status', label: 'Status', type: 'select', - options: [ - { value: '', label: 'All Status' }, - ...(handlers.statusOptions !== undefined - ? handlers.statusOptions - : [ - { value: 'queued', label: 'Queued' }, - { value: 'completed', label: 'Completed' }, - ] - ), - ], + options: handlers.statusOptions, }, { key: 'content_type', label: 'Content Type', type: 'select', - options: [ - { value: '', label: 'All Types' }, - ...(handlers.contentTypeOptions !== undefined - ? handlers.contentTypeOptions - : CONTENT_TYPE_OPTIONS - ), - ], + options: handlers.contentTypeOptions, }, { key: 'content_structure', label: 'Content Structure', type: 'select', - options: [ - { value: '', label: 'All Structures' }, - ...(handlers.contentStructureOptions !== undefined - ? handlers.contentStructureOptions - : [ - { 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' }, - ] - ), - ], + options: handlers.contentStructureOptions, }, { key: 'cluster_id', label: 'Cluster', type: 'select', - options: (() => { - return [ - { value: '', label: 'All Clusters' }, - ...(handlers.clusterOptions !== undefined - ? handlers.clusterOptions - : handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })) - ), - ]; - })(), + options: handlers.clusterOptions, dynamicOptions: 'clusters', }, ], diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx index b062dfb0..90a7d160 100644 --- a/frontend/src/pages/Writer/Approved.tsx +++ b/frontend/src/pages/Writer/Approved.tsx @@ -99,6 +99,7 @@ export default function Approved() { // Load dynamic filter options based on current site's data and applied filters // This implements cascading filters - each filter's options reflect what's available // given the other currently applied filters + // APPROVED PAGE: Always constrain to approved+published statuses const loadFilterOptions = useCallback(async (currentFilters?: { status?: string; site_status?: string; @@ -109,7 +110,11 @@ export default function Approved() { if (!activeSite) return; try { - const options = await fetchWriterContentFilterOptions(activeSite.id, currentFilters); + // Always pass status__in to constrain filter options to approved/published content only + const options = await fetchWriterContentFilterOptions(activeSite.id, { + ...currentFilters, + status__in: 'approved,published', // Base constraint for this page + }); setStatusOptions(options.statuses || []); setSiteStatusOptions(options.site_statuses || []); setContentTypeOptions(options.content_types || []); @@ -119,7 +124,7 @@ export default function Approved() { } }, [activeSite]); - // Load filter options when site changes (initial load with no filters) + // Load filter options when site changes (initial load with approved/published constraint) useEffect(() => { loadFilterOptions(); }, [activeSite]); @@ -175,6 +180,7 @@ export default function Approved() { ...(searchTerm && { search: searchTerm }), // Default to approved+published if no status filter selected ...(statusFilter ? { status: statusFilter } : { status__in: 'approved,published' }), + ...(siteStatusFilter && { site_status: siteStatusFilter }), ...(contentTypeFilter && { content_type: contentTypeFilter }), ...(contentStructureFilter && { content_structure: contentStructureFilter }), page: currentPage, @@ -184,13 +190,7 @@ export default function Approved() { const data: ContentListResponse = await fetchContent(filters); - // Client-side filter for site_status if needed (backend may not support this filter yet) - let filteredResults = data.results || []; - if (siteStatusFilter) { - filteredResults = filteredResults.filter(c => c.site_status === siteStatusFilter); - } - - setContent(filteredResults); + setContent(data.results || []); setTotalCount(data.count || 0); setTotalPages(Math.ceil((data.count || 0) / pageSize)); @@ -771,6 +771,14 @@ export default function Approved() { setCurrentPage(1); } }} + onFilterReset={() => { + setSearchTerm(''); + setStatusFilter(''); + setSiteStatusFilter(''); + setContentTypeFilter(''); + setContentStructureFilter(''); + setCurrentPage(1); + }} pagination={{ currentPage, totalPages, diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx index 8ad4f396..0be3e1c0 100644 --- a/frontend/src/pages/Writer/Content.tsx +++ b/frontend/src/pages/Writer/Content.tsx @@ -77,6 +77,7 @@ export default function Content() { // Load dynamic filter options based on current site's data and applied filters // This implements cascading filters - each filter's options reflect what's available // given the other currently applied filters + // DRAFTS PAGE: Always constrain to draft status const loadFilterOptions = useCallback(async (currentFilters?: { status?: string; site_status?: string; @@ -88,7 +89,11 @@ export default function Content() { if (!activeSite) return; try { - const options = await fetchWriterContentFilterOptions(activeSite.id, currentFilters); + // Always pass status='draft' to constrain filter options to draft content only + const options = await fetchWriterContentFilterOptions(activeSite.id, { + ...currentFilters, + status: 'draft', // Base constraint for this page + }); setSourceOptions(options.sources || []); setContentTypeOptions(options.content_types || []); setContentStructureOptions(options.content_structures || []); @@ -97,7 +102,7 @@ export default function Content() { } }, [activeSite]); - // Load filter options when site changes (initial load with no filters) + // Load filter options when site changes (initial load with draft constraint) useEffect(() => { loadFilterOptions(); }, [activeSite]); @@ -105,13 +110,12 @@ export default function Content() { // Reload filter options when any filter changes (cascading filters) useEffect(() => { loadFilterOptions({ - status: statusFilter || undefined, content_type: contentTypeFilter || undefined, content_structure: contentStructureFilter || undefined, source: sourceFilter || undefined, search: searchTerm || undefined, }); - }, [statusFilter, contentTypeFilter, contentStructureFilter, sourceFilter, searchTerm, loadFilterOptions]); + }, [contentTypeFilter, contentStructureFilter, sourceFilter, searchTerm, loadFilterOptions]); // Load total metrics for footer widget and header metrics (site-wide totals, no sector filter) @@ -422,6 +426,13 @@ export default function Content() { setCurrentPage(1); } }} + onFilterReset={() => { + setSearchTerm(''); + setSourceFilter(''); + setContentTypeFilter(''); + setContentStructureFilter(''); + setCurrentPage(1); + }} pagination={{ currentPage, totalPages, diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx index 446aa607..f83ce9ff 100644 --- a/frontend/src/pages/Writer/Images.tsx +++ b/frontend/src/pages/Writer/Images.tsx @@ -213,7 +213,7 @@ export default function Images() { setShowContent(true); setLoading(false); } - }, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, toast]); + }, [currentPage, statusFilter, contentStatusFilter, sortBy, sortDirection, searchTerm, toast]); useEffect(() => { loadImages(); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index adcd10f2..16aad506 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -893,6 +893,7 @@ export interface WriterContentFilterOptions { export interface ContentFilterOptionsRequest { status?: string; + status__in?: string; // Comma-separated list for base constraint (e.g., 'approved,published') site_status?: string; content_type?: string; content_structure?: string; @@ -907,6 +908,7 @@ export async function fetchWriterContentFilterOptions( const params = new URLSearchParams(); if (siteId) params.append('site_id', siteId.toString()); if (filters?.status) params.append('status', filters.status); + if (filters?.status__in) params.append('status__in', filters.status__in); if (filters?.site_status) params.append('site_status', filters.site_status); if (filters?.content_type) params.append('content_type', filters.content_type); if (filters?.content_structure) params.append('content_structure', filters.content_structure); @@ -2582,6 +2584,7 @@ export interface ContentFilters { search?: string; status?: string; status__in?: string; // Comma-separated list of statuses (e.g., 'approved,published') + site_status?: string; // Site publishing status (not_published, scheduled, published, failed) content_type?: string; content_structure?: string; source?: string; @@ -2674,6 +2677,11 @@ export async function fetchContent(filters: ContentFilters = {}): Promise