Files
igny8/docs/FILTER_GUIDELINES.md
IGNY8 VPS (Salman) cbb32b1c9d filter fixes
2026-01-19 10:49:01 +00:00

5.2 KiB

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)
@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:

// 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:

// 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:

<TablePageTemplate
  filters={pageConfig.filters}
  filterValues={{
    search: searchTerm,
    content_type: contentTypeFilter,
  }}
  onFilterChange={(key, value) => { /* ... */ }}
  onFilterReset={() => {
    setSearchTerm('');
    setContentTypeFilter('');
    setCurrentPage(1);
  }}
/>

6. Filter Dependencies

Ensure filter state variables are in useCallback dependency arrays:

// 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):

// BAD - causes duplicate "All Types" entries
options: [
  { value: '', label: 'All Types' },
  ...(handlers.contentTypeOptions || []),
]

Don't forget filter state in useCallback dependencies:

// BAD - contentStatusFilter changes won't reload data
}, [statusFilter, sortBy, searchTerm]);  // Missing contentStatusFilter

Don't forget onFilterReset handler:

// BAD - no Clear button will show
<TablePageTemplate
  onFilterChange={...}
  // Missing: onFilterReset
/>

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