Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion src/app/components/search/FilterSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import React from 'react';
import { BarChart2, Clock, Tag, Users, Search, RotateCcw, X } from 'lucide-react';
import { BarChart2, Clock, Tag, Users, Search, RotateCcw, X, Cpu } from 'lucide-react';
import { FilterState } from '../../hooks/useSearchFilters';

interface FilterSidebarProps {
Expand Down Expand Up @@ -227,6 +227,56 @@ export const FilterSidebar: React.FC<FilterSidebarProps> = ({
</div>
</div>

{/* Node Affinity Filter */}
<div className="glass-panel p-5 rounded-xl">
<h3 className="flex items-center gap-2 text-xs font-mono font-bold text-tech-text uppercase tracking-widest mb-4">
<Cpu className="w-3.5 h-3.5 text-blue-500" /> Node Affinity
</h3>
<p className="text-[11px] text-slate-400 font-sans mb-3 leading-relaxed">
Select target cluster node for query execution and data fetching.
</p>
<div className="space-y-3">
{[
{ id: 'auto', label: 'Auto (Optimized)', desc: 'Dynamic load balancing' },
{ id: 'primary', label: 'Primary Cluster', desc: 'Direct consistency check' },
{ id: 'replica', label: 'Replica Node', desc: 'Fast read-heavy execution' },
{ id: 'edge', label: 'Edge Cache', desc: 'Minimal latency routing' },
].map((node) => {
const isSelected = (filters.nodeAffinity || 'auto') === node.id;
return (
<label key={node.id} className="flex items-start cursor-pointer group">
<input
type="radio"
name="nodeAffinity"
className="hidden"
checked={isSelected}
onChange={() => onFilterChange({ nodeAffinity: node.id })}
/>
<div
className={`w-4 h-4 border rounded-sm flex items-center justify-center mr-3 mt-0.5 transition-colors ${
isSelected ? 'border-primary' : 'border-slate-300 group-hover:border-primary'
}`}
>
<div
className={`w-2 h-2 bg-primary rounded-sm transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0'
}`}
></div>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-600 group-hover:text-primary transition-colors">
{node.label}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{node.desc}
</span>
</div>
</label>
);
})}
</div>
</div>

<button
onClick={onReset}
className="w-full py-3 px-4 bg-slate-50 border border-slate-200 text-slate-600 font-mono text-xs uppercase tracking-wider rounded-lg hover:bg-white hover:border-primary hover:text-primary hover:shadow-glow transition-all duration-300 flex items-center justify-center gap-2 group cursor-pointer"
Expand Down
57 changes: 57 additions & 0 deletions src/app/components/search/__tests__/FilterSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { FilterSidebar } from '../FilterSidebar';
import { FilterState } from '../../../hooks/useSearchFilters';

const defaultFilters: FilterState = {
difficulty: [],
topics: [],
duration: 100,
priceRange: 500,
sort: 'relevance',
instructor: '',
searchTerm: '',
nodeAffinity: 'auto',
};

describe('FilterSidebar (App) Component - Node Affinity', () => {
it('renders all Node Affinity options', () => {
const onFilterChange = vi.fn();
const onReset = vi.fn();

render(
<FilterSidebar
filters={defaultFilters}
onFilterChange={onFilterChange}
onReset={onReset}
/>
);

expect(screen.getByText('Node Affinity')).toBeInTheDocument();
expect(screen.getByText('Auto (Optimized)')).toBeInTheDocument();
expect(screen.getByText('Primary Cluster')).toBeInTheDocument();
expect(screen.getByText('Replica Node')).toBeInTheDocument();
expect(screen.getByText('Edge Cache')).toBeInTheDocument();
});

it('triggers onFilterChange callback when a new node affinity is selected', () => {
const onFilterChange = vi.fn();
const onReset = vi.fn();

render(
<FilterSidebar
filters={defaultFilters}
onFilterChange={onFilterChange}
onReset={onReset}
/>
);

// Click on Replica Node option
const replicaOption = screen.getByText('Replica Node');
fireEvent.click(replicaOption);

expect(onFilterChange).toHaveBeenCalledTimes(1);
expect(onFilterChange).toHaveBeenCalledWith({ nodeAffinity: 'replica' });
});
});
6 changes: 6 additions & 0 deletions src/app/hooks/useSearchFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface FilterState {
sort: string;
instructor: string;
searchTerm: string;
nodeAffinity?: string;
}

export const useSearchFilters = () => {
Expand All @@ -27,6 +28,7 @@ export const useSearchFilters = () => {
sort: searchParams?.get('sort') || 'relevance',
instructor: searchParams?.get('instructor') || '',
searchTerm: searchParams?.get('q') || '',
nodeAffinity: searchParams?.get('affinity') || 'auto',
});

// Sync URL with state changes
Expand Down Expand Up @@ -54,6 +56,9 @@ export const useSearchFilters = () => {
if (filters.searchTerm) {
params.set('q', filters.searchTerm);
}
if (filters.nodeAffinity && filters.nodeAffinity !== 'auto') {
params.set('affinity', filters.nodeAffinity);
}

const newUrl = params.toString() ? `${pathname ?? ''}?${params.toString()}` : pathname ?? '/';
router.replace(newUrl, { scroll: false });
Expand All @@ -77,6 +82,7 @@ export const useSearchFilters = () => {
sort: 'relevance',
instructor: '',
searchTerm: '',
nodeAffinity: 'auto',
});
}, []);

Expand Down
52 changes: 51 additions & 1 deletion src/components/search/FilterSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import React, { useCallback } from 'react';
import { BarChart2, Clock, Tag, Users, Search, RotateCcw, LifeBuoy } from 'lucide-react';
import { BarChart2, Clock, Tag, Users, Search, RotateCcw, LifeBuoy, Cpu } from 'lucide-react';
import { FilterState } from '../../hooks/useSearchFilters';
import { RangeSlider } from '../ui/RangeSlider';
import { MultiSelect } from '../ui/MultiSelect';
Expand Down Expand Up @@ -203,6 +203,56 @@ export const FilterSidebar = React.memo<FilterSidebarProps>(
</div>
</div>

{/* Node Affinity Filter */}
<div className="glass-panel p-5 rounded-xl">
<h3 className="flex items-center gap-2 text-xs font-mono font-bold text-tech-text uppercase tracking-widest mb-4">
<Cpu className="w-3.5 h-3.5 text-blue-500" /> Node Affinity
</h3>
<p className="text-[11px] text-slate-400 font-sans mb-3 leading-relaxed">
Select target cluster node for query execution and data fetching.
</p>
<div className="space-y-3">
{[
{ id: 'auto', label: 'Auto (Optimized)', desc: 'Dynamic load balancing' },
{ id: 'primary', label: 'Primary Cluster', desc: 'Direct consistency check' },
{ id: 'replica', label: 'Replica Node', desc: 'Fast read-heavy execution' },
{ id: 'edge', label: 'Edge Cache', desc: 'Minimal latency routing' },
].map((node) => {
const isSelected = (filters.nodeAffinity || 'auto') === node.id;
return (
<label key={node.id} className="flex items-start cursor-pointer group">
<input
type="radio"
name="nodeAffinity"
className="hidden"
checked={isSelected}
onChange={() => onFilterChange({ nodeAffinity: node.id })}
/>
<div
className={`w-4 h-4 border rounded-sm flex items-center justify-center mr-3 mt-0.5 transition-colors ${
isSelected ? 'border-primary' : 'border-slate-300 group-hover:border-primary'
}`}
>
<div
className={`w-2 h-2 bg-primary rounded-sm transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0'
}`}
></div>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-600 group-hover:text-primary transition-colors">
{node.label}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{node.desc}
</span>
</div>
</label>
);
})}
</div>
</div>

<button
onClick={onReset}
className="w-full py-3 px-4 bg-slate-50 border border-slate-200 text-slate-600 font-mono text-xs uppercase tracking-wider rounded-lg hover:bg-white hover:border-primary hover:text-primary hover:shadow-glow transition-all duration-300 flex items-center justify-center gap-2 group cursor-pointer"
Expand Down
57 changes: 57 additions & 0 deletions src/components/search/__tests__/FilterSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { FilterSidebar } from '../FilterSidebar';
import { FilterState } from '../../../hooks/useSearchFilters';

const defaultFilters: FilterState = {
difficulty: [],
topics: [],
duration: 100,
priceRange: 200,
sort: 'relevance',
instructor: '',
searchTerm: '',
nodeAffinity: 'auto',
};

describe('FilterSidebar Component - Node Affinity', () => {
it('renders all Node Affinity options', () => {
const onFilterChange = vi.fn();
const onReset = vi.fn();

render(
<FilterSidebar
filters={defaultFilters}
onFilterChange={onFilterChange}
onReset={onReset}
/>
);

expect(screen.getByText('Node Affinity')).toBeInTheDocument();
expect(screen.getByText('Auto (Optimized)')).toBeInTheDocument();
expect(screen.getByText('Primary Cluster')).toBeInTheDocument();
expect(screen.getByText('Replica Node')).toBeInTheDocument();
expect(screen.getByText('Edge Cache')).toBeInTheDocument();
});

it('triggers onFilterChange callback when a new node affinity is selected', () => {
const onFilterChange = vi.fn();
const onReset = vi.fn();

render(
<FilterSidebar
filters={defaultFilters}
onFilterChange={onFilterChange}
onReset={onReset}
/>
);

// Click on Primary Cluster option
const primaryOption = screen.getByText('Primary Cluster');
fireEvent.click(primaryOption);

expect(onFilterChange).toHaveBeenCalledTimes(1);
expect(onFilterChange).toHaveBeenCalledWith({ nodeAffinity: 'primary' });
});
});
7 changes: 7 additions & 0 deletions src/hooks/useSearchFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface FilterState {
sort: string;
instructor: string;
searchTerm: string;
nodeAffinity?: string;
}

export const useSearchFilters = () => {
Expand All @@ -29,6 +30,7 @@ export const useSearchFilters = () => {
sort: searchParams?.get('sort') || 'relevance',
instructor: searchParams?.get('instructor') || '',
searchTerm: searchParams?.get('q') || '',
nodeAffinity: searchParams?.get('affinity') || 'auto',
}),
[searchParams],
);
Expand All @@ -55,6 +57,7 @@ export const useSearchFilters = () => {
prev.sort !== next.sort ||
prev.instructor !== next.instructor ||
prev.searchTerm !== next.searchTerm ||
prev.nodeAffinity !== next.nodeAffinity ||
prev.difficulty.join(',') !== next.difficulty.join(',') ||
prev.topics.join(',') !== next.topics.join(',');

Expand Down Expand Up @@ -97,6 +100,9 @@ export const useSearchFilters = () => {
if (filters.searchTerm) {
params.set('q', filters.searchTerm);
}
if (filters.nodeAffinity && filters.nodeAffinity !== 'auto') {
params.set('affinity', filters.nodeAffinity);
}

const pPathname = pathnameRef.current;
const pSearchParams = searchParamsRef.current;
Expand Down Expand Up @@ -136,6 +142,7 @@ export const useSearchFilters = () => {
sort: 'relevance',
instructor: '',
searchTerm: '',
nodeAffinity: 'auto',
});
}, []);

Expand Down
Loading