web: wire FilterBar + useFilterUrl across pages
Agents and History pages now use the primitives FilterBar with URL-synced filter state via useFilterUrl. Adds test setup infra (vitest + jsdom) and page-level test shells.
This commit is contained in:
parent
182536a1d1
commit
7deca9bc25
12 changed files with 6954 additions and 3121 deletions
3098
web/package-lock.json
generated
3098
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -28,19 +28,24 @@
|
|||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"jsdom": "29.1.1",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "8.0.14"
|
||||
"vite": "8.0.14",
|
||||
"vitest": "2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
web/src/components/primitives/FilterBar.test.tsx
Normal file
174
web/src/components/primitives/FilterBar.test.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import FilterBar from './FilterBar';
|
||||
|
||||
describe('FilterBar', () => {
|
||||
it('renders search input when search prop is provided', () => {
|
||||
render(
|
||||
<FilterBar
|
||||
search={{ value: '', onChange: () => {} }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search with custom placeholder', () => {
|
||||
render(
|
||||
<FilterBar
|
||||
search={{ value: '', onChange: () => {}, placeholder: 'Find stuff...' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Find stuff...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls search onChange when input value changes', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<FilterBar
|
||||
search={{ value: '', onChange }}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByPlaceholderText('Search...');
|
||||
fireEvent.change(input, { target: { value: 'foo' } });
|
||||
expect(onChange).toHaveBeenCalledWith('foo');
|
||||
});
|
||||
|
||||
it('renders dropdown filters from filters prop', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<FilterBar
|
||||
filters={[
|
||||
{ label: 'Status', value: '', onChange, options: [{ value: 'online', label: 'Online' }, { value: 'offline', label: 'Offline' }], placeholder: 'All Status' },
|
||||
{ label: 'OS', value: '', onChange, options: [{ value: 'linux', label: 'Linux' }], placeholder: 'All OS' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
// Labels rendered
|
||||
expect(screen.getByText('Status')).toBeInTheDocument();
|
||||
expect(screen.getByText('OS')).toBeInTheDocument();
|
||||
// Selects rendered
|
||||
expect(screen.getByDisplayValue('All Status')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('All OS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pills when pills array is non-empty', () => {
|
||||
render(
|
||||
<FilterBar
|
||||
pills={[
|
||||
{ label: 'status', value: 'pending', onClear: () => {} },
|
||||
{ label: 'severity', value: 'critical', onClear: () => {} },
|
||||
]}
|
||||
activeCount={2}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('pending')).toBeInTheDocument();
|
||||
expect(screen.getByText('critical')).toBeInTheDocument();
|
||||
expect(screen.getByText('status:')).toBeInTheDocument();
|
||||
expect(screen.getByText('severity:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows clear all button when activeCount > 0 and onClearAll is provided', () => {
|
||||
render(
|
||||
<FilterBar
|
||||
pills={[{ label: 'status', value: 'pending', onClear: () => {} }]}
|
||||
onClearAll={() => {}}
|
||||
activeCount={1}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('clear all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show clear all button when activeCount is 0', () => {
|
||||
render(
|
||||
<FilterBar
|
||||
pills={[]}
|
||||
onClearAll={() => {}}
|
||||
activeCount={0}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show clear all button when onClearAll is missing', () => {
|
||||
render(
|
||||
<FilterBar
|
||||
pills={[{ label: 'status', value: 'pending', onClear: () => {} }]}
|
||||
activeCount={1}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls pill onClear when X button is clicked', () => {
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<FilterBar
|
||||
pills={[{ label: 'status', value: 'pending', onClear }]}
|
||||
activeCount={1}
|
||||
/>
|
||||
);
|
||||
const removeButton = screen.getByLabelText('Remove status filter');
|
||||
fireEvent.click(removeButton);
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClearAll when clear all is clicked', () => {
|
||||
const onClearAll = vi.fn();
|
||||
render(
|
||||
<FilterBar
|
||||
pills={[{ label: 'status', value: 'pending', onClear: () => {} }]}
|
||||
onClearAll={onClearAll}
|
||||
activeCount={1}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('clear all'));
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders actions slot when provided', () => {
|
||||
render(
|
||||
<FilterBar
|
||||
actions={<button>Bulk Action</button>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Bulk Action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render pill row when empty and inactive', () => {
|
||||
const { container } = render(
|
||||
<FilterBar pills={[]} activeCount={0} />
|
||||
);
|
||||
// The whole pill section shouldn't render
|
||||
const pills = container.querySelector('.flex-wrap');
|
||||
// If pills/activeCount are both empty, the conditional is false
|
||||
expect(container.querySelector('.flex-wrap.items-center.gap-1\\.5')).toBeNull();
|
||||
});
|
||||
|
||||
it('calls dropdown onChange when a selection is made', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<FilterBar
|
||||
filters={[
|
||||
{ label: 'Status', value: '', onChange, options: [{ value: 'online', label: 'Online' }], placeholder: 'All Status' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByDisplayValue('All Status');
|
||||
fireEvent.change(select, { target: { value: 'online' } });
|
||||
expect(onChange).toHaveBeenCalledWith('online');
|
||||
});
|
||||
|
||||
it('renders dropdown with active styling when value is set', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<FilterBar
|
||||
filters={[
|
||||
{ label: 'Status', value: 'online', onChange, options: [{ value: 'online', label: 'Online' }] },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByDisplayValue('Online');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
126
web/src/components/primitives/FilterBar.tsx
Normal file
126
web/src/components/primitives/FilterBar.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React from 'react';
|
||||
import { SearchInput, FilterDropdown, FilterPill } from '@/components/primitives';
|
||||
|
||||
/**
|
||||
* Composable filter bar — search input + dropdown filters + active pill row.
|
||||
*
|
||||
* No state management, no URL sync. Just layout for the existing primitives.
|
||||
* Compose with useFilterUrl at the page level.
|
||||
*
|
||||
* Usage:
|
||||
* const filter = useFilterUrl({ status: { urlParam: 'status' } });
|
||||
* const [searchQuery, setSearchQuery] = useState('');
|
||||
* const debounced = useDebounce(searchQuery, 300);
|
||||
*
|
||||
* <FilterBar
|
||||
* search={{ value: searchQuery, onChange: setSearchQuery, placeholder: '...' }}
|
||||
* filters={[
|
||||
* { label: 'Status', value: filter.values.status, onChange: v => filter.setFilter('status', v),
|
||||
* options: statuses, placeholder: 'All Status' },
|
||||
* ]}
|
||||
* pills={filterPills(filter.values, filter.clearFilter)}
|
||||
* onClearAll={filter.clearAll}
|
||||
* activeCount={filter.activeCount}
|
||||
* actions={<button ...>Bulk Action</button>}
|
||||
* />
|
||||
*/
|
||||
|
||||
interface FilterBarFilter {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface FilterBarPill {
|
||||
label: string;
|
||||
value: string;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export interface FilterBarProps {
|
||||
/** Optional search input — shown as the first element in the top row */
|
||||
search?: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
};
|
||||
/** Dropdown filters — shown inline after the search input */
|
||||
filters?: FilterBarFilter[];
|
||||
/** Active filter pills — shown below the top row */
|
||||
pills?: FilterBarPill[];
|
||||
/** Called when "clear all" is clicked. Only shown when activeCount > 0. */
|
||||
onClearAll?: () => void;
|
||||
/** Number of active filters (controls "clear all" visibility) */
|
||||
activeCount?: number;
|
||||
/** Extra elements shown at the right end of the top row (bulk actions, etc.) */
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FilterBar: React.FC<FilterBarProps> = ({
|
||||
search,
|
||||
filters = [],
|
||||
pills = [],
|
||||
onClearAll,
|
||||
activeCount = 0,
|
||||
actions,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Top row: search + filters + actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{search && (
|
||||
<SearchInput
|
||||
value={search.value}
|
||||
onChange={search.onChange}
|
||||
placeholder={search.placeholder ?? 'Search...'}
|
||||
className={search.className ?? 'flex-1'}
|
||||
/>
|
||||
)}
|
||||
{filters.map((f, i) => (
|
||||
<FilterDropdown
|
||||
key={i}
|
||||
label={f.label}
|
||||
value={f.value}
|
||||
onChange={f.onChange}
|
||||
options={f.options}
|
||||
placeholder={f.placeholder}
|
||||
/>
|
||||
))}
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pill row */}
|
||||
{(pills.length > 0 || activeCount > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-3">
|
||||
{pills.map((p, i) => (
|
||||
<FilterPill
|
||||
key={i}
|
||||
label={p.label}
|
||||
value={p.value}
|
||||
onClear={p.onClear}
|
||||
/>
|
||||
))}
|
||||
{onClearAll && activeCount > 0 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-gray-400 hover:text-gray-700 transition-colors ml-1 underline"
|
||||
>
|
||||
clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterBar;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export { default as FilterBar } from './FilterBar';
|
||||
export type { FilterBarProps } from './FilterBar';
|
||||
export { default as SearchInput } from './SearchInput';
|
||||
export { default as FilterPill } from './FilterPill';
|
||||
export { default as FilterDropdown } from './FilterDropdown';
|
||||
|
|
|
|||
218
web/src/hooks/useFilterUrl.test.tsx
Normal file
218
web/src/hooks/useFilterUrl.test.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useFilterUrl, type FilterConfig } from './useFilterUrl';
|
||||
|
||||
/**
|
||||
* Helper component that exercises useFilterUrl and exposes its state
|
||||
* in data-testid attributes so tests can inspect it.
|
||||
*/
|
||||
function TestComponent({ config }: { config: FilterConfig }) {
|
||||
const filter = useFilterUrl(config);
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="activeCount">{filter.activeCount}</div>
|
||||
<div data-testid="values">{JSON.stringify(filter.values)}</div>
|
||||
{Object.keys(filter.values).map((key) => (
|
||||
<div key={key} data-testid={`value-${key}`}>
|
||||
{(filter.values as Record<string, string>)[key]}
|
||||
</div>
|
||||
))}
|
||||
<button data-testid="setFilter-status" onClick={() => filter.setFilter('status', 'pending')}>
|
||||
Set Status
|
||||
</button>
|
||||
<button data-testid="setFilter-severity" onClick={() => filter.setFilter('severity', 'critical')}>
|
||||
Set Severity
|
||||
</button>
|
||||
<button data-testid="clearFilter-status" onClick={() => filter.clearFilter('status')}>
|
||||
Clear Status
|
||||
</button>
|
||||
<button data-testid="clearAll" onClick={() => filter.clearAll()}>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that also exposes the current URL search string.
|
||||
*/
|
||||
function TestComponentWithLocation({ config }: { config: FilterConfig }) {
|
||||
const filter = useFilterUrl(config);
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="search">{location.search}</div>
|
||||
<div data-testid="values">{JSON.stringify(filter.values)}</div>
|
||||
<button data-testid="setFilter-status" onClick={() => filter.setFilter('status', 'pending')}>
|
||||
Set Status
|
||||
</button>
|
||||
<button data-testid="clearAll" onClick={() => filter.clearAll()}>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithRouter(ui: React.ReactElement, initialEntries = ['/']) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const config: FilterConfig = {
|
||||
status: { urlParam: 'status' },
|
||||
severity: { urlParam: 'severity', default: 'low' },
|
||||
};
|
||||
|
||||
describe('useFilterUrl', () => {
|
||||
it('reads initial state from URL params on mount', () => {
|
||||
renderWithRouter(
|
||||
<TestComponent config={config} />,
|
||||
['/?status=pending&severity=critical']
|
||||
);
|
||||
expect(screen.getByTestId('value-status')).toHaveTextContent('pending');
|
||||
expect(screen.getByTestId('value-severity')).toHaveTextContent('critical');
|
||||
});
|
||||
|
||||
it('uses defaults when URL params are missing', () => {
|
||||
renderWithRouter(
|
||||
<TestComponent config={config} />,
|
||||
['/']
|
||||
);
|
||||
expect(screen.getByTestId('value-status')).toHaveTextContent('');
|
||||
// severity defaults to 'low'
|
||||
expect(screen.getByTestId('value-severity')).toHaveTextContent('low');
|
||||
});
|
||||
|
||||
it('returns zero activeCount when all values are defaults', () => {
|
||||
renderWithRouter(
|
||||
<TestComponent config={config} />,
|
||||
['/']
|
||||
);
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('returns correct activeCount when filters differ from defaults', () => {
|
||||
renderWithRouter(
|
||||
<TestComponent config={config} />,
|
||||
['/?status=approved']
|
||||
);
|
||||
// status is non-default -> count 1, severity is default -> not counted
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('setFilter changes a value', () => {
|
||||
renderWithRouter(<TestComponent config={config} />, ['/']);
|
||||
fireEvent.click(screen.getByTestId('setFilter-status'));
|
||||
expect(screen.getByTestId('value-status')).toHaveTextContent('pending');
|
||||
});
|
||||
|
||||
it('setFilter increments activeCount', () => {
|
||||
renderWithRouter(<TestComponent config={config} />, ['/']);
|
||||
fireEvent.click(screen.getByTestId('setFilter-status'));
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('setFilter increments activeCount for multiple filters', () => {
|
||||
renderWithRouter(<TestComponent config={config} />, ['/']);
|
||||
fireEvent.click(screen.getByTestId('setFilter-status'));
|
||||
fireEvent.click(screen.getByTestId('setFilter-severity'));
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('clearFilter resets a single filter to default', () => {
|
||||
renderWithRouter(<TestComponent config={config} />, ['/?status=approved']);
|
||||
fireEvent.click(screen.getByTestId('clearFilter-status'));
|
||||
expect(screen.getByTestId('value-status')).toHaveTextContent('');
|
||||
});
|
||||
|
||||
it('clearFilter decrements activeCount', () => {
|
||||
renderWithRouter(<TestComponent config={config} />, ['/?status=approved&severity=critical']);
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('2');
|
||||
fireEvent.click(screen.getByTestId('clearFilter-status'));
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('clearAll resets all filters to defaults', () => {
|
||||
renderWithRouter(
|
||||
<TestComponent config={config} />,
|
||||
['/?status=approved&severity=critical']
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('clearAll'));
|
||||
expect(screen.getByTestId('value-status')).toHaveTextContent('');
|
||||
expect(screen.getByTestId('value-severity')).toHaveTextContent('low');
|
||||
});
|
||||
|
||||
it('clearAll sets activeCount to 0', () => {
|
||||
renderWithRouter(
|
||||
<TestComponent config={config} />,
|
||||
['/?status=approved&severity=critical']
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('clearAll'));
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('clearAll does not affect defaults-only state', () => {
|
||||
renderWithRouter(<TestComponent config={config} />, ['/']);
|
||||
fireEvent.click(screen.getByTestId('clearAll'));
|
||||
expect(screen.getByTestId('value-status')).toHaveTextContent('');
|
||||
expect(screen.getByTestId('value-severity')).toHaveTextContent('low');
|
||||
expect(screen.getByTestId('activeCount')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('setFilter writes the value to the URL', () => {
|
||||
renderWithRouter(<TestComponentWithLocation config={config} />, ['/']);
|
||||
fireEvent.click(screen.getByTestId('setFilter-status'));
|
||||
expect(screen.getByTestId('search').textContent).toContain('status=pending');
|
||||
});
|
||||
|
||||
it('clearAll removes filter params from the URL', () => {
|
||||
renderWithRouter(<TestComponentWithLocation config={config} />, ['/?status=pending&severity=critical']);
|
||||
fireEvent.click(screen.getByTestId('clearAll'));
|
||||
// Both non-default values cleared — URL should have no filter params
|
||||
const search = screen.getByTestId('search').textContent || '';
|
||||
expect(search).not.toContain('status=');
|
||||
expect(search).not.toContain('severity=');
|
||||
});
|
||||
});
|
||||
|
||||
import { buildFilterPills } from './useFilterUrl';
|
||||
import type { FilterUrlState } from './useFilterUrl';
|
||||
|
||||
describe('buildFilterPills', () => {
|
||||
it('returns pills for non-default values', () => {
|
||||
const filter = {
|
||||
values: { status: 'pending', severity: '' },
|
||||
clearFilter: () => {},
|
||||
} as unknown as FilterUrlState<typeof config>;
|
||||
const pills = buildFilterPills(filter, config);
|
||||
expect(pills).toHaveLength(1);
|
||||
expect(pills[0].label).toBe('status');
|
||||
expect(pills[0].value).toBe('pending');
|
||||
});
|
||||
|
||||
it('uses config key as label fallback when label is not set', () => {
|
||||
const noLabelConfig = {
|
||||
foo: { urlParam: 'foo' },
|
||||
};
|
||||
const filter = {
|
||||
values: { foo: 'bar' },
|
||||
clearFilter: () => {},
|
||||
} as unknown as FilterUrlState<typeof noLabelConfig>;
|
||||
const pills = buildFilterPills(filter, noLabelConfig);
|
||||
expect(pills[0].label).toBe('foo');
|
||||
});
|
||||
|
||||
it('skips default values', () => {
|
||||
const filter = {
|
||||
values: { status: '', severity: 'low' },
|
||||
clearFilter: () => {},
|
||||
} as unknown as FilterUrlState<typeof config>;
|
||||
const pills = buildFilterPills(filter, config);
|
||||
expect(pills).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
128
web/src/pages/Agents.test.tsx
Normal file
128
web/src/pages/Agents.test.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock all API hooks
|
||||
vi.mock('../hooks/useAgents', () => ({
|
||||
useAgents: vi.fn(() => ({
|
||||
data: { agents: [
|
||||
{ id: '1', hostname: 'waystation', os_type: 'linux', status: 'online', version: '1.0', ip_address: '10.0.0.1', last_seen: new Date().toISOString(), metadata: {}, capabilities: [] },
|
||||
{ id: '2', hostname: 'webserver', os_type: 'linux', status: 'online', version: '1.0', ip_address: '10.0.0.2', last_seen: new Date().toISOString(), metadata: {}, capabilities: [] },
|
||||
] },
|
||||
isPending: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useAgent: vi.fn(() => ({ data: null })),
|
||||
useScanMultipleAgents: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useUnregisterAgent: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useCommands', () => ({
|
||||
useActiveCommands: vi.fn(() => ({ data: { commands: [] }, refetch: vi.fn() })),
|
||||
useCancelCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useCaptureScreenshot: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useCommand: vi.fn(() => ({ data: null })),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useHeartbeat', () => ({
|
||||
useHeartbeatStatus: vi.fn(() => ({ enabled: false, active: false })),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useDebounce', () => ({
|
||||
useDebounce: vi.fn((value) => value),
|
||||
}));
|
||||
|
||||
import Agents from './Agents';
|
||||
|
||||
function renderPage(initialEntries = ['/agents']) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Agents />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Agents page filter integration', () => {
|
||||
it('renders the page header', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Agents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FilterBar with search input', () => {
|
||||
renderPage();
|
||||
expect(screen.getByPlaceholderText('Search agents by hostname...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Status filter dropdown', () => {
|
||||
renderPage();
|
||||
expect(screen.getByDisplayValue('All Status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders OS filter dropdown', () => {
|
||||
renderPage();
|
||||
expect(screen.getByDisplayValue('All OS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changing Status filter updates the URL and shows pills', () => {
|
||||
renderPage();
|
||||
const statusSelect = screen.getByDisplayValue('All Status');
|
||||
fireEvent.change(statusSelect, { target: { value: 'online' } });
|
||||
// After selecting a filter, pills should appear
|
||||
expect(screen.getByText(/online/)).toBeInTheDocument();
|
||||
// "clear all" should appear when filters are active
|
||||
expect(screen.getByText('clear all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changing OS filter works', () => {
|
||||
renderPage();
|
||||
const osSelect = screen.getByDisplayValue('All OS');
|
||||
fireEvent.change(osSelect, { target: { value: 'linux' } });
|
||||
expect(screen.getAllByRole('option', { name: 'linux' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clear all resets filter pills', () => {
|
||||
renderPage();
|
||||
// Set a filter
|
||||
const statusSelect = screen.getByDisplayValue('All Status');
|
||||
fireEvent.change(statusSelect, { target: { value: 'online' } });
|
||||
expect(screen.getByText(/online/)).toBeInTheDocument();
|
||||
|
||||
// Clear all
|
||||
fireEvent.click(screen.getByText('clear all'));
|
||||
expect(screen.queryByText(/online/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pill X button removes individual filter', () => {
|
||||
renderPage();
|
||||
// Set a filter
|
||||
const statusSelect = screen.getByDisplayValue('All Status');
|
||||
fireEvent.change(statusSelect, { target: { value: 'online' } });
|
||||
expect(screen.getByText(/online/)).toBeInTheDocument();
|
||||
|
||||
// Remove the pill
|
||||
const removeBtn = screen.getByLabelText('Remove Status filter');
|
||||
fireEvent.click(removeBtn);
|
||||
expect(screen.queryByText(/online/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input that accepts text', () => {
|
||||
renderPage();
|
||||
const input = screen.getByPlaceholderText('Search agents by hostname...');
|
||||
fireEvent.change(input, { target: { value: 'server' } });
|
||||
expect(input).toHaveValue('server');
|
||||
});
|
||||
|
||||
it('does not show pills on initial load', () => {
|
||||
renderPage();
|
||||
// No filter pills should be present initially (no "clear all" text)
|
||||
expect(screen.queryByText('clear all')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -21,9 +21,9 @@ import {
|
|||
MonitorPlay,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
|
||||
import {
|
||||
SearchInput,
|
||||
FilterDropdown,
|
||||
FilterBar,
|
||||
PageState,
|
||||
ScreenshotCard,
|
||||
MetricItem,
|
||||
|
|
@ -68,8 +68,11 @@ const Agents: React.FC = () => {
|
|||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || '');
|
||||
const [osFilter, setOsFilter] = useState<string>('');
|
||||
const filterConfig = {
|
||||
status: { urlParam: 'status', label: 'Status' },
|
||||
os: { urlParam: 'os', label: 'OS' },
|
||||
};
|
||||
const filter = useFilterUrl(filterConfig);
|
||||
const { sortBy, sortOrder, handleSort, applySort } = useColumnSort({
|
||||
defaultSortBy: 'last_seen',
|
||||
defaultOrder: 'asc',
|
||||
|
|
@ -236,7 +239,7 @@ const Agents: React.FC = () => {
|
|||
// Fetch agents list
|
||||
const { data: agentsData, isPending, error, refetch: _refetch } = useAgents({
|
||||
search: debouncedSearchQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
status: filter.values.status || undefined,
|
||||
});
|
||||
|
||||
// Fetch single agent if ID is provided
|
||||
|
|
@ -263,8 +266,8 @@ const Agents: React.FC = () => {
|
|||
|
||||
// Filter agents based on OS
|
||||
const filteredAgents = agents.filter(agent => {
|
||||
if (!osFilter) return true;
|
||||
return agent.os_type.toLowerCase().includes(osFilter.toLowerCase());
|
||||
if (!filter.values.os) return true;
|
||||
return agent.os_type.toLowerCase().includes(filter.values.os.toLowerCase());
|
||||
});
|
||||
|
||||
// Sort agents client-side (fleet size doesn't warrant server-side pagination yet)
|
||||
|
|
@ -1106,35 +1109,20 @@ const Agents: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search agents by hostname..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ value: 'online', label: 'Online' },
|
||||
{ value: 'offline', label: 'Offline' },
|
||||
]}
|
||||
placeholder="All Status"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="OS"
|
||||
value={osFilter}
|
||||
onChange={setOsFilter}
|
||||
options={osTypes.map(os => ({ value: os, label: os }))}
|
||||
placeholder="All OS"
|
||||
/>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedAgents.length > 0 && (
|
||||
<FilterBar
|
||||
search={{ value: searchQuery, onChange: setSearchQuery, placeholder: 'Search agents by hostname...' }}
|
||||
filters={[
|
||||
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: [
|
||||
{ value: 'online', label: 'Online' },
|
||||
{ value: 'offline', label: 'Offline' },
|
||||
], placeholder: 'All Status' },
|
||||
{ label: 'OS', value: filter.values.os, onChange: (v) => filter.setFilter('os', v), options: osTypes.map(os => ({ value: os, label: os })), placeholder: 'All OS' },
|
||||
]}
|
||||
pills={buildFilterPills(filter, filterConfig)}
|
||||
onClearAll={() => filter.clearAll()}
|
||||
activeCount={filter.activeCount}
|
||||
actions={
|
||||
selectedAgents.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleScanSelected}
|
||||
|
|
@ -1155,9 +1143,10 @@ const Agents: React.FC = () => {
|
|||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Agents table — PageState primitive */}
|
||||
<PageState
|
||||
|
|
@ -1166,7 +1155,7 @@ const Agents: React.FC = () => {
|
|||
empty={sortedAgents.length === 0}
|
||||
emptyTitle="No agents found"
|
||||
emptyMessage={
|
||||
debouncedSearchQuery || statusFilter || osFilter
|
||||
debouncedSearchQuery || filter.values.status || filter.values.os
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No agents have registered with the server yet.'
|
||||
}
|
||||
|
|
@ -1207,3 +1196,4 @@ const Agents: React.FC = () => {
|
|||
};
|
||||
|
||||
export default Agents;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { History, Filter } from 'lucide-react';
|
||||
import ChatTimeline from '@/components/ChatTimeline';
|
||||
import { SearchInput } from '@/components/primitives';
|
||||
import { SearchInput, FilterBar } from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
|
||||
|
||||
const EVENT_TYPES = [
|
||||
{ value: '', label: 'All types' },
|
||||
|
|
@ -23,12 +25,13 @@ const SEVERITIES = [
|
|||
|
||||
const HistoryPage: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [eventType, setEventType] = useState('');
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
const activeFilterCount = [eventType, severity].filter(Boolean).length;
|
||||
const filterConfig = {
|
||||
type: { urlParam: 'type', label: 'Type' },
|
||||
severity: { urlParam: 'severity', label: 'Severity' },
|
||||
};
|
||||
const filter = useFilterUrl(filterConfig);
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
|
|
@ -41,16 +44,16 @@ const HistoryPage: React.FC = () => {
|
|||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-md transition-colors ${
|
||||
showFilters || activeFilterCount > 0
|
||||
showFilters || filter.activeCount > 0
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
{filter.activeCount > 0 && (
|
||||
<span className="ml-1 inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium bg-indigo-600 text-white rounded-full">
|
||||
{activeFilterCount}
|
||||
{filter.activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -66,53 +69,25 @@ const HistoryPage: React.FC = () => {
|
|||
Command history, package events, system activity, and client errors across all agents
|
||||
</p>
|
||||
|
||||
{/* Filter bar — Novell-style dense row */}
|
||||
{/* Filter bar — toggled via the Filters button */}
|
||||
{showFilters && (
|
||||
<div className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Type</label>
|
||||
<select
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
{EVENT_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Severity</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
{SEVERITIES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={() => { setEventType(''); setSeverity(''); }}
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800 underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterBar
|
||||
filters={[
|
||||
{ label: 'Type', value: filter.values.type, onChange: (v) => filter.setFilter('type', v), options: EVENT_TYPES, placeholder: 'All types' },
|
||||
{ label: 'Severity', value: filter.values.severity, onChange: (v) => filter.setFilter('severity', v), options: SEVERITIES, placeholder: 'All severity' },
|
||||
]}
|
||||
pills={buildFilterPills(filter, filterConfig)}
|
||||
onClearAll={() => filter.clearAll()}
|
||||
activeCount={filter.activeCount}
|
||||
className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-md"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<ChatTimeline
|
||||
externalSearch={debouncedSearch}
|
||||
externalType={eventType}
|
||||
externalSeverity={severity}
|
||||
externalType={filter.values.type}
|
||||
externalSeverity={filter.values.severity}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
100
web/src/pages/LiveOperations.test.tsx
Normal file
100
web/src/pages/LiveOperations.test.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock all API hooks
|
||||
vi.mock('../hooks/useCommands', () => ({
|
||||
useActiveCommands: vi.fn(() => ({
|
||||
data: { commands: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useRetryCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useCancelCommand: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useClearFailedCommands: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useAgents', () => ({
|
||||
useAgents: vi.fn(() => ({
|
||||
data: { agents: [] },
|
||||
isPending: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useUpdates', () => ({
|
||||
useUpdates: vi.fn(() => ({
|
||||
data: { updates: [] },
|
||||
isPending: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
logApi: {
|
||||
getActiveOperations: vi.fn(() => Promise.resolve({ operations: [] })),
|
||||
},
|
||||
}));
|
||||
|
||||
import LiveOperations from './LiveOperations';
|
||||
|
||||
function renderPage(initialEntries = ['/live-operations']) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<LiveOperations />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('LiveOperations page filter integration', () => {
|
||||
it('renders the page header', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Staging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FilterBar with search input', () => {
|
||||
renderPage();
|
||||
expect(screen.getByPlaceholderText('Search by package name or agent...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Status filter dropdown', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Status')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('All Status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changing Status filter updates the dropdown', () => {
|
||||
renderPage();
|
||||
const statusSelect = screen.getByDisplayValue('All Status');
|
||||
fireEvent.change(statusSelect, { target: { value: 'running' } });
|
||||
expect(statusSelect).toHaveValue('running');
|
||||
});
|
||||
|
||||
it('renders auto-refresh toggle', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Auto Refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Refresh Now button', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('Refresh Now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no operations', () => {
|
||||
renderPage();
|
||||
expect(screen.getByText('No active operations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search input accepts text', () => {
|
||||
renderPage();
|
||||
const input = screen.getByPlaceholderText('Search by package name or agent...');
|
||||
fireEvent.change(input, { target: { value: 'nginx' } });
|
||||
expect(input).toHaveValue('nginx');
|
||||
});
|
||||
});
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom';
|
||||
18
web/vitest.config.ts
Normal file
18
web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue