diff --git a/docs/blacklist-filters-saved-views.md b/docs/blacklist-filters-saved-views.md index 797ac35..6c3dc8c 100644 --- a/docs/blacklist-filters-saved-views.md +++ b/docs/blacklist-filters-saved-views.md @@ -1,6 +1,6 @@ # Blacklist: Filter Chips & Saved Views (Design Spec) -Status: Draft +Status: Implemented (Issue #431) Purpose - Provide repeatable filters and saved views for large blacklists. @@ -21,6 +21,34 @@ Edge cases - Many chips wrapping: collapse into "+N" overflow menu. - Keyboard accessibility for overflow menu and chip removal. -Next steps -- Implement `src/pages/DistributionDashboard.tsx` UI and saved-views API hooks. -- Add tests and accessibility checks. +--- + +## Implementation notes + +Implemented on the Distribution Dashboard in a self-contained `BlacklistFiltersPanel`: + +- `src/components/BlacklistFilterChips/` — multi-select chip bar + - Chip states: default, active (`aria-pressed="true"`), disabled (`:disabled`), and overflow ("+N more" popover menu). + - Multi-select per group (source, severity, region, created date); Shift+Click selects a contiguous range within a group. + - Keyboard: ArrowLeft/Right/Up/Down navigation, Home/End, Enter/Space toggle, Delete/Backspace removes the focused chip. + - WCAG 2.1 AA: every chip is a real ` + + ); + })} + + {hasOverflow && ( +
+ + + {overflowOpen && ( + + )} +
+ )} + + + ); +}; diff --git a/src/components/BlacklistFilterChips/BlacklistFilterChips.types.ts b/src/components/BlacklistFilterChips/BlacklistFilterChips.types.ts new file mode 100644 index 0000000..ecbfae1 --- /dev/null +++ b/src/components/BlacklistFilterChips/BlacklistFilterChips.types.ts @@ -0,0 +1,59 @@ +/** + * BlacklistFilterChips — Issue #431 + * + * Multi-select filter chip bar for the blacklist. Supports the chip states + * defined in `docs/blacklist-filters-saved-views.md`: + * default, active (selected), disabled, overflow ("+N" menu). + */ + +export type BlacklistFilterKey = 'source' | 'severity' | 'region' | 'createdDate'; + +/** Multi-select selection keyed by filter group. */ +export type BlacklistFilterSelection = Record; + +export const EMPTY_FILTER_SELECTION: BlacklistFilterSelection = { + source: [], + severity: [], + region: [], + createdDate: [], +}; + +export interface FilterChipOption { + /** Stable identifier (used as the selected value). */ + id: string; + /** Human-readable label shown on the chip. */ + label: string; + /** Which filter group this chip belongs to. */ + group: BlacklistFilterKey; + /** Renders the chip in the disabled state (e.g. deprecated source types). */ + disabled?: boolean; + /** Optional count badge, e.g. the number of matching entries. */ + count?: number; + /** Assistive hint shown as a native tooltip. */ + title?: string; +} + +export const BLACKLIST_FILTER_GROUPS: ReadonlyArray<{ + key: BlacklistFilterKey; + label: string; +}> = [ + { key: 'source', label: 'Source' }, + { key: 'severity', label: 'Severity' }, + { key: 'region', label: 'Region' }, + { key: 'createdDate', label: 'Created' }, +]; + +export interface BlacklistFilterChipsProps { + /** All chip options in display order. */ + options: FilterChipOption[]; + /** Current multi-select selection. */ + selection: BlacklistFilterSelection; + /** Fired whenever a chip is toggled or removed. */ + onChange: (selection: BlacklistFilterSelection) => void; + /** Chips beyond this count collapse into the "+N" overflow menu. */ + maxVisibleChips?: number; + /** Disables every chip in the bar (e.g. while data is loading). */ + disabled?: boolean; + /** Accessible label for the chip bar. */ + 'aria-label'?: string; +} diff --git a/src/components/BlacklistFilterChips/index.ts b/src/components/BlacklistFilterChips/index.ts new file mode 100644 index 0000000..ab522dc --- /dev/null +++ b/src/components/BlacklistFilterChips/index.ts @@ -0,0 +1,2 @@ +export * from './BlacklistFilterChips'; +export * from './BlacklistFilterChips.types'; diff --git a/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.css b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.css new file mode 100644 index 0000000..cefcf4c --- /dev/null +++ b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.css @@ -0,0 +1,225 @@ +/** + * BlacklistFiltersPanel — Issue #431 + * + * Styles for the blacklist section rendered on the Distribution Dashboard: + * header row (title + saved views), filter chip bar, result list and + * empty state. Uses existing design-system tokens from index.css :root. + */ + +:root { + --bl-panel-border: rgba(148, 163, 184, 0.15); + --bl-severity-critical: #ef4444; + --bl-severity-high: #f97316; + --bl-severity-medium: #f59e0b; + --bl-severity-low: #10b981; +} + +.blacklist-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-md, 1rem); +} + +.blacklist-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md, 1rem); + flex-wrap: wrap; +} + +.blacklist-panel-heading { + margin: 0; + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-main, #e5e7eb); +} + +.blacklist-panel-actions { + display: flex; + align-items: center; + gap: var(--spacing-sm, 0.75rem); + flex-wrap: wrap; +} + +.blacklist-reset-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border-radius: var(--radius-lg, 0.75rem); + font-size: var(--font-size-sm, 0.875rem); + background: transparent; + border: 1px solid var(--bl-panel-border); + color: var(--text-muted, #cbd5e1); + cursor: pointer; + transition: all 0.15s ease; +} + +.blacklist-reset-btn:hover:not(:disabled) { + border-color: var(--error, #ef4444); + color: var(--error, #ef4444); +} + +.blacklist-reset-btn:focus-visible { + outline: 2px solid var(--primary, #3b82f6); + outline-offset: 2px; +} + +.blacklist-reset-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ─── Result list ─────────────────────────────────────────────────── */ + +.blacklist-results-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm, 0.75rem); + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-muted, #cbd5e1); +} + +.blacklist-results-count { + font-weight: var(--font-weight-semibold, 600); + color: var(--text-accent, #38bdf8); +} + +.blacklist-entries { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-xs, 0.5rem); +} + +.blacklist-entry { + display: grid; + grid-template-columns: minmax(0, 1.4fr) auto auto auto minmax(0, 1fr); + align-items: center; + gap: var(--spacing-md, 1rem); + padding: var(--spacing-sm, 0.75rem) var(--spacing-md, 1rem); + background: var(--glass-bg-accent, rgba(30, 41, 59, 0.5)); + border: 1px solid var(--bl-panel-border); + border-radius: var(--radius-lg, 0.75rem); + transition: border-color 0.15s ease; +} + +.blacklist-entry:hover { + border-color: var(--glass-border-bright, rgba(148, 163, 184, 0.35)); +} + +.blacklist-entry-value { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-main, #e5e7eb); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.blacklist-entry-source { + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-semibold, 600); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted, #cbd5e1); +} + +.blacklist-severity-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-semibold, 600); + border-radius: var(--radius-full, 9999px); + padding: 0.1875rem 0.625rem; + border: 1px solid transparent; +} + +.blacklist-severity-badge[data-severity='Critical'] { + color: var(--bl-severity-critical); + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.35); +} + +.blacklist-severity-badge[data-severity='High'] { + color: var(--bl-severity-high); + background: rgba(249, 115, 22, 0.12); + border-color: rgba(249, 115, 22, 0.35); +} + +.blacklist-severity-badge[data-severity='Medium'] { + color: var(--bl-severity-medium); + background: rgba(245, 158, 11, 0.12); + border-color: rgba(245, 158, 11, 0.35); +} + +.blacklist-severity-badge[data-severity='Low'] { + color: var(--bl-severity-low); + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.35); +} + +.blacklist-entry-region { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-muted, #cbd5e1); +} + +.blacklist-entry-date { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-muted, #cbd5e1); +} + +.blacklist-entry-reason { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-muted, #cbd5e1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ─── Empty state ─────────────────────────────────────────────────── */ + +.blacklist-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs, 0.5rem); + padding: var(--spacing-3xl, 3rem) var(--spacing-md, 1rem); + text-align: center; + background: var(--glass-bg-accent, rgba(30, 41, 59, 0.5)); + border: 1px dashed var(--bl-panel-border); + border-radius: var(--radius-lg, 0.75rem); + color: var(--text-muted, #cbd5e1); +} + +.blacklist-empty svg { + color: var(--text-accent, #38bdf8); +} + +.blacklist-empty-title { + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-main, #e5e7eb); +} + +.blacklist-empty-hint { + font-size: var(--font-size-sm, 0.875rem); + max-width: 28rem; +} + +/* Mobile: stack entry columns */ +@media (max-width: 720px) { + .blacklist-entry { + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--spacing-xs, 0.5rem); + } + + .blacklist-entry-reason { + grid-column: 1 / -1; + } +} diff --git a/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.test.tsx b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.test.tsx new file mode 100644 index 0000000..f240a7c --- /dev/null +++ b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, useSearchParams } from 'react-router-dom'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { BlacklistFiltersPanel } from './BlacklistFiltersPanel'; +import { + STORAGE_KEY_BLACKLIST_VIEWS, + type BlacklistFiltersPanelProps, +} from './BlacklistFiltersPanel.types'; + +expect.extend(toHaveNoViolations); + +const LocationProbe = () => { + const [params] = useSearchParams(); + return {params.toString()}; +}; + +const renderPanel = ( + initialEntries = ['/distribution-dashboard'], + props: Partial = {} +) => { + return render( + + + + + ); +}; + +const entryCount = () => + within(screen.getByTestId('blacklist-entries')).getAllByRole('listitem').length; + +describe('BlacklistFiltersPanel', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('renders the blacklist section with chips, saved views and entries', () => { + renderPanel(); + + expect(screen.getByRole('heading', { name: 'Blacklist' })).toBeInTheDocument(); + expect(screen.getByTestId('blacklist-filter-chips')).toBeInTheDocument(); + expect(screen.getByTestId('blacklist-saved-views-trigger')).toBeInTheDocument(); + expect(entryCount()).toBe(10); + }); + + it('filters entries when a severity chip is selected', () => { + renderPanel(); + + fireEvent.click(screen.getByTestId('chip-critical')); + + expect(entryCount()).toBe(2); + expect(screen.getByText('0x3f5C…9aB1')).toBeInTheDocument(); + expect(screen.getByText('phish-site.example.org')).toBeInTheDocument(); + expect(screen.queryByText('203.0.113.42')).not.toBeInTheDocument(); + }); + + it('supports multi-select across groups', () => { + renderPanel(); + + fireEvent.click(screen.getByTestId('chip-critical')); + fireEvent.click(screen.getByTestId('chip-na')); + + // Critical entries in North America: none -> empty state + expect(screen.getByTestId('blacklist-empty')).toBeInTheDocument(); + }); + + it('renders an empty state when nothing matches and allows resetting', () => { + renderPanel(); + + fireEvent.click(screen.getByTestId('chip-ip')); + fireEvent.click(screen.getByTestId('chip-critical')); + + expect(screen.getByTestId('blacklist-empty')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('blacklist-reset-btn')); + expect(screen.queryByTestId('blacklist-empty')).not.toBeInTheDocument(); + expect(entryCount()).toBe(10); + }); + + it('applies a saved view and reports it as the active view', () => { + renderPanel(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-apply-view-critical')); + + expect(entryCount()).toBe(4); + expect(screen.getByText(/View: Critical & High/)).toBeInTheDocument(); + }); + + it('saves a new view to localStorage when filters are active', () => { + renderPanel(); + + fireEvent.click(screen.getByTestId('chip-wallet')); + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.change(screen.getByTestId('blacklist-saved-views-new-input'), { + target: { value: 'Wallet only' }, + }); + fireEvent.click(screen.getByTestId('blacklist-saved-views-save-btn')); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY_BLACKLIST_VIEWS) || '[]'); + expect(stored.some((view: { name: string }) => view.name === 'Wallet only')).toBe(true); + expect(screen.getByTestId('blacklist-saved-views-trigger')).toHaveTextContent('3'); + }); + + it('seeds filters and saved-view state from URL params', () => { + renderPanel(['/distribution-dashboard?blSeverity=critical']); + + expect(screen.getByTestId('chip-critical')).toHaveAttribute('aria-pressed', 'true'); + expect(entryCount()).toBe(2); + }); + + it('writes the active selection to URL params', () => { + renderPanel(); + + fireEvent.click(screen.getByTestId('chip-wallet')); + + expect(screen.getByTestId('location-params')).toHaveTextContent('blSource=wallet'); + }); + + it('shares a saved view by copying a URL with encoded filters', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + renderPanel(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-share-view-critical')); + + expect(writeText).toHaveBeenCalledTimes(1); + const url = writeText.mock.calls[0][0] as string; + expect(url).toContain('blSeverity=critical'); + expect(url).toContain('blView=view-critical'); + expect(screen.getByTestId('saved-view-copied-view-critical')).toHaveTextContent('Link copied!'); + }); + + it('renders the saved-views error state when loading fails', () => { + renderPanel(['/distribution-dashboard'], { simulateLoadError: true }); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + expect(screen.getByTestId('blacklist-saved-views-error')).toBeInTheDocument(); + }); + + it('renders the saved-views empty state when there are no views', () => { + renderPanel(['/distribution-dashboard'], { defaultViews: [] }); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + expect(screen.getByTestId('blacklist-saved-views-empty')).toBeInTheDocument(); + }); + + it('has no axe accessibility violations', async () => { + const { container } = renderPanel(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }, 60000); +}); diff --git a/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.tsx b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.tsx new file mode 100644 index 0000000..837f569 --- /dev/null +++ b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.tsx @@ -0,0 +1,372 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { RotateCcw, SearchX } from 'lucide-react'; +import { BlacklistFilterChips } from '../BlacklistFilterChips/BlacklistFilterChips'; +import { + EMPTY_FILTER_SELECTION, + type BlacklistFilterSelection, +} from '../BlacklistFilterChips/BlacklistFilterChips.types'; +import { BlacklistSavedViews } from '../BlacklistSavedViews/BlacklistSavedViews'; +import type { BlacklistSavedView } from '../BlacklistSavedViews/BlacklistSavedViews.types'; +import { + BLACKLIST_FILTER_OPTIONS, + STORAGE_KEY_BLACKLIST_VIEWS, + type BlacklistEntry, + type BlacklistFiltersPanelProps, +} from './BlacklistFiltersPanel.types'; +import './BlacklistFiltersPanel.css'; + +const daysAgo = (days: number): string => { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString(); +}; + +export const MOCK_BLACKLIST_ENTRIES: BlacklistEntry[] = [ + { id: 'bl-1', value: '0x3f5C…9aB1', source: 'Wallet', severity: 'Critical', regionId: 'eu', regionLabel: 'Europe', createdDate: daysAgo(0), reason: 'Suspected laundering pattern' }, + { id: 'bl-2', value: '203.0.113.42', source: 'IP', severity: 'High', regionId: 'na', regionLabel: 'North America', createdDate: daysAgo(1), reason: 'Repeated brute-force login attempts' }, + { id: 'bl-3', value: 'fraud-actor@example.com', source: 'Email', severity: 'High', regionId: 'latam', regionLabel: 'Latin America', createdDate: daysAgo(3), reason: 'Chargeback abuse' }, + { id: 'bl-4', value: 'bad-pool.example.com', source: 'Domain', severity: 'Medium', regionId: 'apac', regionLabel: 'Asia Pacific', createdDate: daysAgo(6), reason: 'Known scam infrastructure' }, + { id: 'bl-5', value: '0x7Bc2…dD44', source: 'Wallet', severity: 'Medium', regionId: 'na', regionLabel: 'North America', createdDate: daysAgo(9), reason: 'Tornado Cash exposure' }, + { id: 'bl-6', value: '198.51.100.77', source: 'IP', severity: 'Low', regionId: 'mea', regionLabel: 'Middle East & Africa', createdDate: daysAgo(15), reason: 'Spam traffic source' }, + { id: 'bl-7', value: 'phish-site.example.org', source: 'Domain', severity: 'Critical', regionId: 'eu', regionLabel: 'Europe', createdDate: daysAgo(22), reason: 'Active credential phishing' }, + { id: 'bl-8', value: 'suspicious@test.io', source: 'Email', severity: 'Medium', regionId: 'apac', regionLabel: 'Asia Pacific', createdDate: daysAgo(34), reason: 'Synthetic identity cluster' }, + { id: 'bl-9', value: '0x9d12…0fC7', source: 'Wallet', severity: 'Low', regionId: 'na', regionLabel: 'North America', createdDate: daysAgo(58), reason: 'Sanctions-list overlap' }, + { id: 'bl-10', value: '192.0.2.15', source: 'IP', severity: 'Low', regionId: 'eu', regionLabel: 'Europe', createdDate: daysAgo(81), reason: 'Legacy blocked range' }, +]; + +const DEFAULT_SAVED_VIEWS: BlacklistSavedView[] = [ + { + id: 'view-critical', + name: 'Critical & High', + isDefault: true, + createdAt: daysAgo(5), + filters: { source: [], severity: ['critical', 'high'], region: [], createdDate: [] }, + }, + { + id: 'view-na-90d', + name: 'North America · 90d', + createdAt: daysAgo(12), + filters: { source: [], severity: [], region: ['na'], createdDate: ['90d'] }, + }, +]; + +const readSelectionFromUrl = (searchParams: URLSearchParams): BlacklistFilterSelection => { + const read = (key: string) => searchParams.getAll(key); + return { + source: read('blSource'), + severity: read('blSeverity'), + region: read('blRegion'), + createdDate: read('blCreated'), + }; +}; + +const writeSelectionToUrl = ( + searchParams: URLSearchParams, + selection: BlacklistFilterSelection, + viewId: string | null +): URLSearchParams => { + const next = new URLSearchParams(searchParams); + const groups: Array<[string, string[]]> = [ + ['blSource', selection.source], + ['blSeverity', selection.severity], + ['blRegion', selection.region], + ['blCreated', selection.createdDate], + ]; + groups.forEach(([key, values]) => { + next.delete(key); + values.forEach((value) => next.append(key, value)); + }); + if (viewId) { + next.set('blView', viewId); + } else { + next.delete('blView'); + } + return next; +}; + +const isWithinCreatedRange = (isoDate: string, rangeId: string): boolean => { + const createdAt = new Date(isoDate).getTime(); + const now = Date.now(); + const dayMs = 86_400_000; + switch (rangeId) { + case 'today': + return new Date(isoDate).toDateString() === new Date(now).toDateString(); + case '7d': + return createdAt >= now - 7 * dayMs; + case '30d': + return createdAt >= now - 30 * dayMs; + case '90d': + return createdAt >= now - 90 * dayMs; + default: + return false; + } +}; + +const buildShareUrl = (view: BlacklistSavedView): string => { + const params = new URLSearchParams(); + const groups: Array<[string, string[]]> = [ + ['blSource', view.filters.source], + ['blSeverity', view.filters.severity], + ['blRegion', view.filters.region], + ['blCreated', view.filters.createdDate], + ]; + groups.forEach(([key, values]) => { + values.forEach((value) => params.append(key, value)); + }); + params.set('blView', view.id); + const query = params.toString(); + return `${window.location.origin}${window.location.pathname}${query ? `?${query}` : ''}`; +}; + +export const BlacklistFiltersPanel: React.FC = ({ + entries = MOCK_BLACKLIST_ENTRIES, + defaultViews, + simulateLoadError = false, +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const [selection, setSelection] = useState(() => + readSelectionFromUrl(searchParams) + ); + const [activeViewId, setActiveViewId] = useState(() => + searchParams.get('blView') + ); + const [savedViews, setSavedViews] = useState(() => { + if (simulateLoadError) return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY_BLACKLIST_VIEWS); + if (raw) return JSON.parse(raw) as BlacklistSavedView[]; + } catch { + // Fall back to defaults below. + } + return defaultViews ?? DEFAULT_SAVED_VIEWS; + }); + const [viewsLoadFailed, setViewsLoadFailed] = useState(simulateLoadError); + + const hasActiveFilters = useMemo( + () => Object.values(selection).some((ids) => ids.length > 0), + [selection] + ); + + const persistViews = useCallback((views: BlacklistSavedView[]) => { + setSavedViews(views); + try { + window.localStorage.setItem(STORAGE_KEY_BLACKLIST_VIEWS, JSON.stringify(views)); + } catch { + // Storage may be unavailable (e.g. private mode); ignore. + } + }, []); + + const applySelection = useCallback( + (nextSelection: BlacklistFilterSelection, viewId: string | null) => { + setSelection(nextSelection); + setActiveViewId(viewId); + setSearchParams(writeSelectionToUrl(searchParams, nextSelection, viewId)); + }, + [searchParams, setSearchParams] + ); + + const handleFilterChange = useCallback( + (nextSelection: BlacklistFilterSelection) => { + // Manually editing filters detaches the applied saved view. + applySelection(nextSelection, null); + }, + [applySelection] + ); + + const handleResetFilters = useCallback(() => { + applySelection(EMPTY_FILTER_SELECTION, null); + }, [applySelection]); + + const handleApplyView = useCallback( + (view: BlacklistSavedView) => { + applySelection(view.filters, view.id); + }, + [applySelection] + ); + + const handleSaveView = useCallback( + (name: string, filters: BlacklistFilterSelection) => { + const newView: BlacklistSavedView = { + id: `view-${Date.now()}`, + name, + filters: { + source: [...filters.source], + severity: [...filters.severity], + region: [...filters.region], + createdDate: [...filters.createdDate], + }, + createdAt: new Date().toISOString(), + }; + persistViews([...savedViews, newView]); + }, + [savedViews, persistViews] + ); + + const handleRenameView = useCallback( + (id: string, name: string) => { + persistViews(savedViews.map((view) => (view.id === id ? { ...view, name } : view))); + }, + [savedViews, persistViews] + ); + + const handleDeleteView = useCallback( + (id: string) => { + persistViews(savedViews.filter((view) => view.id !== id)); + if (activeViewId === id) { + setActiveViewId(null); + setSearchParams((params) => { + const next = new URLSearchParams(params); + next.delete('blView'); + return next; + }); + } + }, + [savedViews, persistViews, activeViewId, setSearchParams] + ); + + const handleSetDefaultView = useCallback( + (id: string) => { + const target = savedViews.find((view) => view.id === id); + if (!target) return; + const willBeDefault = !target.isDefault; + persistViews( + savedViews.map((view) => ({ + ...view, + isDefault: view.id === id ? willBeDefault : false, + })) + ); + }, + [savedViews, persistViews] + ); + + const handleShareView = useCallback(async (view: BlacklistSavedView) => { + const url = buildShareUrl(view); + try { + await navigator.clipboard.writeText(url); + } catch { + // Fallback for older browsers / non-secure contexts. + const textarea = document.createElement('textarea'); + textarea.value = url; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + }, []); + + const filteredEntries = useMemo(() => { + return entries.filter((entry) => { + if (selection.source.length && !selection.source.includes(entry.source.toLowerCase())) { + return false; + } + if (selection.severity.length && !selection.severity.includes(entry.severity.toLowerCase())) { + return false; + } + if (selection.region.length && !selection.region.includes(entry.regionId)) { + return false; + } + if ( + selection.createdDate.length && + !selection.createdDate.some((range) => isWithinCreatedRange(entry.createdDate, range)) + ) { + return false; + } + return true; + }); + }, [entries, selection]); + + return ( +
+
+

+ Blacklist +

+
+ + +
+
+ + + +
+

+ Showing {filteredEntries.length} of{' '} + {entries.length} entries + {activeViewId && savedViews.find((view) => view.id === activeViewId) + ? ` · View: ${savedViews.find((view) => view.id === activeViewId)!.name}` + : ''} +

+ {hasActiveFilters && ( +

+ Filters are shareable — use the saved-view link icon. +

+ )} +
+ + {filteredEntries.length === 0 ? ( +
+
+ ) : ( +
    + {filteredEntries.map((entry) => ( +
  • + + {entry.value} + + {entry.source} + + {entry.severity} + + {entry.regionLabel} + + {new Date(entry.createdDate).toLocaleDateString()} + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.types.ts b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.types.ts new file mode 100644 index 0000000..29ca515 --- /dev/null +++ b/src/components/BlacklistFiltersPanel/BlacklistFiltersPanel.types.ts @@ -0,0 +1,64 @@ +/** + * BlacklistFiltersPanel — Issue #431 + * + * Orchestrates the blacklist filter chip bar, saved views, URL sharing + * (`?blSource=…&blSeverity=…&blRegion=…&blCreated=…&blView=…`) and the + * filtered blacklist result list. + */ + +import type { FilterChipOption } from '../BlacklistFilterChips/BlacklistFilterChips.types'; +import type { BlacklistSavedView } from '../BlacklistSavedViews/BlacklistSavedViews.types'; + +export type BlacklistEntrySource = 'Wallet' | 'IP' | 'Email' | 'Domain'; +export type BlacklistEntrySeverity = 'Critical' | 'High' | 'Medium' | 'Low'; + +export interface BlacklistEntry { + id: string; + /** The blocked value (address, IP, email, or domain). */ + value: string; + source: BlacklistEntrySource; + severity: BlacklistEntrySeverity; + /** Matches the region chip id (e.g. `na`, `eu`). */ + regionId: string; + regionLabel: string; + /** ISO date string. */ + createdDate: string; + /** Free-form note shown in the list. */ + reason: string; +} + +export interface BlacklistFiltersPanelProps { + /** Overrides the default mock entries (used by tests). */ + entries?: BlacklistEntry[]; + /** Overrides the default saved views (used by tests). */ + defaultViews?: BlacklistSavedView[]; + /** Simulates a saved-views fetch failure (used to show the error state). */ + simulateLoadError?: boolean; +} + +export const STORAGE_KEY_BLACKLIST_VIEWS = 'revora_blacklist_saved_views'; + +export const BLACKLIST_FILTER_OPTIONS: FilterChipOption[] = [ + // Source + { id: 'wallet', label: 'Wallet', group: 'source' }, + { id: 'ip', label: 'IP Address', group: 'source' }, + { id: 'email', label: 'Email', group: 'source' }, + { id: 'domain', label: 'Domain', group: 'source' }, + { id: 'legacy-wallet', label: 'Legacy Wallet', group: 'source', disabled: true, title: 'Deprecated source type' }, + // Severity + { id: 'critical', label: 'Critical', group: 'severity' }, + { id: 'high', label: 'High', group: 'severity' }, + { id: 'medium', label: 'Medium', group: 'severity' }, + { id: 'low', label: 'Low', group: 'severity' }, + // Region + { id: 'na', label: 'North America', group: 'region' }, + { id: 'eu', label: 'Europe', group: 'region' }, + { id: 'apac', label: 'Asia Pacific', group: 'region' }, + { id: 'latam', label: 'Latin America', group: 'region' }, + { id: 'mea', label: 'Middle East & Africa', group: 'region' }, + // Created date + { id: 'today', label: 'Today', group: 'createdDate' }, + { id: '7d', label: 'Last 7 days', group: 'createdDate' }, + { id: '30d', label: 'Last 30 days', group: 'createdDate' }, + { id: '90d', label: 'Last 90 days', group: 'createdDate' }, +]; diff --git a/src/components/BlacklistFiltersPanel/index.ts b/src/components/BlacklistFiltersPanel/index.ts new file mode 100644 index 0000000..d6a23b2 --- /dev/null +++ b/src/components/BlacklistFiltersPanel/index.ts @@ -0,0 +1,2 @@ +export * from './BlacklistFiltersPanel'; +export * from './BlacklistFiltersPanel.types'; diff --git a/src/components/BlacklistSavedViews/BlacklistSavedViews.css b/src/components/BlacklistSavedViews/BlacklistSavedViews.css new file mode 100644 index 0000000..d7dbb25 --- /dev/null +++ b/src/components/BlacklistSavedViews/BlacklistSavedViews.css @@ -0,0 +1,342 @@ +/** + * BlacklistSavedViews — Issue #431 + * + * Saved-views dropdown with apply / rename / default / share / delete. + * Uses existing design-system tokens from index.css :root. + */ + +:root { + --bl-sv-row-hover: rgba(51, 65, 85, 0.6); + --bl-sv-row-active-bg: rgba(59, 130, 246, 0.12); + --bl-sv-icon-btn: rgba(148, 163, 184, 0.9); + --bl-sv-icon-btn-hover: #ffffff; + --bl-sv-danger: #f87171; +} + +/* ─── Trigger ─────────────────────────────────────────────────────── */ + +.blacklist-saved-views { + position: relative; + display: inline-flex; +} + +.blacklist-saved-views-trigger { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border-radius: var(--radius-lg, 0.75rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + background: var(--glass-bg-accent, rgba(30, 41, 59, 0.5)); + border: 1px solid var(--glass-border, rgba(148, 163, 184, 0.15)); + color: var(--text-main, #e5e7eb); + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease, + background 0.15s ease; +} + +.blacklist-saved-views-trigger:hover, +.blacklist-saved-views-trigger.is-open { + border-color: var(--primary, #3b82f6); + color: var(--text-accent, #38bdf8); +} + +.blacklist-saved-views-trigger:focus-visible { + outline: 2px solid var(--primary, #3b82f6); + outline-offset: 2px; +} + +.blacklist-saved-views-count { + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-accent, #38bdf8); + background: rgba(59, 130, 246, 0.15); + border-radius: var(--radius-full, 9999px); + padding: 0.0625rem 0.375rem; +} + +/* ─── Panel ───────────────────────────────────────────────────────── */ + +.blacklist-saved-views-panel { + position: absolute; + top: calc(100% + 4px); + inset-inline-end: 0; + z-index: 40; + width: 320px; + max-width: min(320px, calc(100vw - 2rem)); + background: var(--glass-bg, rgba(15, 23, 42, 0.85)); + border: 1px solid var(--glass-border, rgba(148, 163, 184, 0.15)); + border-radius: var(--radius-lg, 0.75rem); + box-shadow: var(--shadow-xl); + backdrop-filter: var(--glass-blur, blur(12px)); + overflow: hidden; +} + +.blacklist-saved-views-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm, 0.75rem) var(--spacing-md, 1rem); + border-bottom: 1px solid var(--glass-border, rgba(148, 163, 184, 0.15)); +} + +.blacklist-saved-views-title { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-main, #e5e7eb); +} + +.blacklist-saved-views-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--radius-sm, 0.375rem); + background: transparent; + border: none; + color: var(--text-muted, #cbd5e1); + cursor: pointer; +} + +.blacklist-saved-views-close:hover { + background: var(--bl-sv-row-hover); + color: #ffffff; +} + +/* ─── List ────────────────────────────────────────────────────────── */ + +.blacklist-saved-views-list { + list-style: none; + margin: 0; + padding: var(--spacing-xs, 0.5rem); + display: flex; + flex-direction: column; + gap: 2px; + max-height: 260px; + overflow-y: auto; +} + +.blacklist-saved-view-row { + display: flex; + align-items: center; + gap: var(--spacing-xs, 0.5rem); + padding: 0.375rem 0.5rem; + border-radius: var(--radius-md, 0.5rem); + position: relative; +} + +.blacklist-saved-view-row.is-active { + background: var(--bl-sv-row-active-bg); +} + +.blacklist-saved-view-row:hover { + background: var(--bl-sv-row-hover); +} + +.blacklist-saved-view-name { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0; + background: transparent; + border: none; + color: var(--text-main, #e5e7eb); + font-size: var(--font-size-sm, 0.875rem); + text-align: start; + cursor: pointer; +} + +.blacklist-saved-view-name:hover { + color: var(--text-accent, #38bdf8); +} + +.blacklist-saved-view-default-badge { + font-size: var(--font-size-3xs, 0.625rem); + font-weight: var(--font-weight-semibold, 600); + text-transform: uppercase; + letter-spacing: 0.06em; + color: #fbbf24; + background: rgba(251, 191, 36, 0.12); + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: var(--radius-full, 9999px); + padding: 0.0625rem 0.375rem; +} + +.blacklist-saved-view-actions { + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.blacklist-saved-view-row:hover .blacklist-saved-view-actions, +.blacklist-saved-view-actions:focus-within { + opacity: 1; +} + +.blacklist-saved-view-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + border-radius: var(--radius-sm, 0.375rem); + background: transparent; + border: none; + color: var(--bl-sv-icon-btn); + cursor: pointer; +} + +.blacklist-saved-view-actions button:hover { + background: rgba(59, 130, 246, 0.15); + color: var(--bl-sv-icon-btn-hover); +} + +.blacklist-saved-view-actions button.is-danger:hover { + background: rgba(239, 68, 68, 0.15); + color: var(--bl-sv-danger); +} + +.blacklist-saved-view-actions button.is-default { + color: #fbbf24; +} + +.blacklist-saved-view-actions button:focus-visible { + outline: 2px solid var(--primary, #3b82f6); + outline-offset: 1px; +} + +.blacklist-saved-view-copied { + position: absolute; + inset-inline-end: 0.5rem; + bottom: -0.75rem; + font-size: var(--font-size-3xs, 0.625rem); + color: var(--success, #10b981); +} + +/* ─── Rename (inline) ─────────────────────────────────────────────── */ + +.blacklist-saved-view-rename { + flex: 1; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.blacklist-saved-view-rename input { + flex: 1; + min-width: 0; + background: rgba(15, 23, 42, 0.7); + border: 1px solid var(--primary, #3b82f6); + border-radius: var(--radius-sm, 0.375rem); + padding: 0.25rem 0.5rem; + color: #ffffff; + font-size: var(--font-size-sm, 0.875rem); +} + +.blacklist-saved-view-rename button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + border-radius: var(--radius-sm, 0.375rem); + background: transparent; + border: none; + color: var(--success, #10b981); + cursor: pointer; +} + +.blacklist-saved-view-rename button:hover { + background: rgba(16, 185, 129, 0.15); +} + +/* ─── Empty / error states ────────────────────────────────────────── */ + +.blacklist-saved-views-empty, +.blacklist-saved-views-error { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-2xs, 0.25rem); + padding: var(--spacing-xl, 1.5rem) var(--spacing-md, 1rem); + text-align: center; + color: var(--text-muted, #cbd5e1); + font-size: var(--font-size-sm, 0.875rem); +} + +.blacklist-saved-views-empty svg { + color: var(--text-accent, #38bdf8); + margin-bottom: var(--spacing-2xs, 0.25rem); +} + +.blacklist-saved-views-error svg { + color: var(--error, #ef4444); + margin-bottom: var(--spacing-2xs, 0.25rem); +} + +.blacklist-saved-views-empty-hint, +.blacklist-saved-views-error-hint { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-muted, #cbd5e1); + opacity: 0.75; +} + +/* ─── Footer (save current filters) ───────────────────────────────── */ + +.blacklist-saved-views-footer { + display: flex; + align-items: center; + gap: var(--spacing-xs, 0.5rem); + padding: var(--spacing-sm, 0.75rem) var(--spacing-md, 1rem); + border-top: 1px solid var(--glass-border, rgba(148, 163, 184, 0.15)); +} + +.blacklist-saved-views-new-input { + flex: 1; + min-width: 0; + background: rgba(15, 23, 42, 0.7); + border: 1px solid var(--glass-border, rgba(148, 163, 184, 0.15)); + border-radius: var(--radius-md, 0.5rem); + padding: 0.375rem 0.625rem; + color: #ffffff; + font-size: var(--font-size-sm, 0.875rem); +} + +.blacklist-saved-views-new-input:focus-visible { + outline: 2px solid var(--primary, #3b82f6); + outline-offset: 1px; +} + +.blacklist-saved-views-save-btn { + padding: 0.375rem 0.75rem; + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-semibold, 600); + background: var(--primary, #3b82f6); + border: 1px solid transparent; + color: #ffffff; + cursor: pointer; + transition: background 0.15s ease; +} + +.blacklist-saved-views-save-btn:hover:not(:disabled) { + background: var(--primary-hover, #2563eb); +} + +.blacklist-saved-views-save-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* RTL: keep the panel anchored to the trigger edge */ +[dir='rtl'] .blacklist-saved-views-panel { + inset-inline-end: 0; +} diff --git a/src/components/BlacklistSavedViews/BlacklistSavedViews.test.tsx b/src/components/BlacklistSavedViews/BlacklistSavedViews.test.tsx new file mode 100644 index 0000000..f3f59f7 --- /dev/null +++ b/src/components/BlacklistSavedViews/BlacklistSavedViews.test.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { BlacklistSavedViews } from './BlacklistSavedViews'; +import type { BlacklistSavedView } from './BlacklistSavedViews.types'; +import type { BlacklistFilterSelection } from '../BlacklistFilterChips/BlacklistFilterChips.types'; + +expect.extend(toHaveNoViolations); + +const EMPTY_SELECTION: BlacklistFilterSelection = { + source: [], + severity: [], + region: [], + createdDate: [], +}; + +const VIEWS: BlacklistSavedView[] = [ + { + id: 'v1', + name: 'Critical & High', + isDefault: true, + createdAt: '2026-07-01T00:00:00Z', + filters: { ...EMPTY_SELECTION, severity: ['critical', 'high'] }, + }, + { + id: 'v2', + name: 'North America · 90d', + createdAt: '2026-07-05T00:00:00Z', + filters: { ...EMPTY_SELECTION, region: ['na'], createdDate: ['90d'] }, + }, +]; + +describe('BlacklistSavedViews', () => { + const handlers = { + onApplyView: vi.fn(), + onSaveView: vi.fn(), + onRenameView: vi.fn(), + onDeleteView: vi.fn(), + onSetDefaultView: vi.fn(), + onShareView: vi.fn(), + }; + + const defaultProps = { + views: VIEWS, + activeViewId: null, + currentFilters: EMPTY_SELECTION, + hasActiveFilters: false, + hasError: false, + ...handlers, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the trigger with the saved views count', () => { + render(); + + const trigger = screen.getByTestId('blacklist-saved-views-trigger'); + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent('Saved Views'); + expect(trigger).toHaveTextContent('2'); + }); + + it('opens the panel and lists saved views with the default badge', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + + expect(screen.getByTestId('blacklist-saved-views-panel')).toBeInTheDocument(); + expect(screen.getByText('Critical & High')).toBeInTheDocument(); + expect(screen.getByText('North America · 90d')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + }); + + it('applies a view when its name is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-apply-v2')); + + expect(handlers.onApplyView).toHaveBeenCalledWith(VIEWS[1]); + }); + + it('renames a view via the inline editor', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-rename-v1')); + + const renameInput = screen.getByTestId('saved-view-rename-input'); + expect(renameInput).toHaveValue('Critical & High'); + + fireEvent.change(renameInput, { target: { value: 'Critical only' } }); + fireEvent.click(screen.getByTestId('saved-view-rename-confirm')); + + expect(handlers.onRenameView).toHaveBeenCalledWith('v1', 'Critical only'); + }); + + it('cancels rename with Escape without reporting', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-rename-v1')); + fireEvent.keyDown(screen.getByTestId('saved-view-rename-input'), { key: 'Escape' }); + + expect(handlers.onRenameView).not.toHaveBeenCalled(); + }); + + it('sets a view as the default', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-default-v2')); + + expect(handlers.onSetDefaultView).toHaveBeenCalledWith('v2'); + }); + + it('deletes a view', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-delete-v2')); + + expect(handlers.onDeleteView).toHaveBeenCalledWith('v2'); + }); + + it('shares a view and shows the copied confirmation', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.click(screen.getByTestId('saved-view-share-v1')); + + expect(handlers.onShareView).toHaveBeenCalledWith(VIEWS[0]); + expect(screen.getByTestId('saved-view-copied-v1')).toHaveTextContent('Link copied!'); + }); + + it('saves the current filters under a new name', () => { + const activeFilters: BlacklistFilterSelection = { + ...EMPTY_SELECTION, + region: ['eu'], + }; + render( + + ); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.change(screen.getByTestId('blacklist-saved-views-new-input'), { + target: { value: 'EU entries' }, + }); + fireEvent.click(screen.getByTestId('blacklist-saved-views-save-btn')); + + expect(handlers.onSaveView).toHaveBeenCalledWith('EU entries', activeFilters); + }); + + it('disables the save button when there are no active filters', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + fireEvent.change(screen.getByTestId('blacklist-saved-views-new-input'), { + target: { value: 'Anything' }, + }); + + expect(screen.getByTestId('blacklist-saved-views-save-btn')).toBeDisabled(); + }); + + it('renders the empty state when there are no saved views', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + expect(screen.getByTestId('blacklist-saved-views-empty')).toBeInTheDocument(); + expect(screen.getByText('No saved views yet.')).toBeInTheDocument(); + }); + + it('renders the error state when views failed to load', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + expect(screen.getByTestId('blacklist-saved-views-error')).toBeInTheDocument(); + expect(screen.getByText(/couldn't load your saved views/i)).toBeInTheDocument(); + }); + + it('closes the panel with Escape', () => { + render(); + + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + expect(screen.getByTestId('blacklist-saved-views-panel')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByTestId('blacklist-saved-views-panel')).not.toBeInTheDocument(); + }); + + it('has no axe accessibility violations', async () => { + const { container } = render(); + fireEvent.click(screen.getByTestId('blacklist-saved-views-trigger')); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/components/BlacklistSavedViews/BlacklistSavedViews.tsx b/src/components/BlacklistSavedViews/BlacklistSavedViews.tsx new file mode 100644 index 0000000..ed1c9fd --- /dev/null +++ b/src/components/BlacklistSavedViews/BlacklistSavedViews.tsx @@ -0,0 +1,276 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AlertTriangle, Bookmark, Check, ChevronDown, Copy, Pencil, Star, Trash2, X } from 'lucide-react'; +import './BlacklistSavedViews.css'; +import type { BlacklistSavedView, BlacklistSavedViewsProps } from './BlacklistSavedViews.types'; + +export const BlacklistSavedViews: React.FC = ({ + views, + activeViewId, + currentFilters, + hasActiveFilters = false, + hasError = false, + onApplyView, + onSaveView, + onRenameView, + onDeleteView, + onSetDefaultView, + onShareView, +}) => { + const [open, setOpen] = useState(false); + const [newViewName, setNewViewName] = useState(''); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [copiedId, setCopiedId] = useState(null); + const containerRef = useRef(null); + const renameInputRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + setRenamingId(null); + } + }; + window.addEventListener('mousedown', handleClickOutside); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + useEffect(() => { + if (renamingId && renameInputRef.current) { + renameInputRef.current.focus(); + } + }, [renamingId]); + + const handleSaveNew = () => { + const name = newViewName.trim(); + if (!name) return; + onSaveView(name, currentFilters); + setNewViewName(''); + setOpen(false); + }; + + const handleRenameConfirm = () => { + if (!renamingId) return; + const name = renameValue.trim(); + if (name) { + onRenameView(renamingId, name); + } + setRenamingId(null); + }; + + const handleShare = (view: BlacklistSavedView) => { + onShareView(view); + setCopiedId(view.id); + window.setTimeout(() => { + setCopiedId((id) => (id === view.id ? null : id)); + }, 2000); + }; + + const canSave = hasActiveFilters && newViewName.trim().length > 0; + + return ( +
+ + + {open && ( +
+
+ Saved Views + +
+ + {hasError ? ( +
+
+ ) : views.length === 0 ? ( +
+
+ ) : ( +
    + {views.map((view) => { + const isActive = view.id === activeViewId; + const isRenaming = renamingId === view.id; + const isCopied = copiedId === view.id; + return ( +
  • + {isRenaming ? ( +
    + setRenameValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') handleRenameConfirm(); + if (event.key === 'Escape') setRenamingId(null); + }} + aria-label="Rename saved view" + data-testid="saved-view-rename-input" + /> + +
    + ) : ( + <> + +
    + + + + +
    + {isCopied && ( + + Link copied! + + )} + + )} +
  • + ); + })} +
+ )} + +
+ setNewViewName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') handleSaveNew(); + }} + aria-label="New saved view name" + data-testid="blacklist-saved-views-new-input" + /> + +
+
+ )} +
+ ); +}; diff --git a/src/components/BlacklistSavedViews/BlacklistSavedViews.types.ts b/src/components/BlacklistSavedViews/BlacklistSavedViews.types.ts new file mode 100644 index 0000000..9f2ee0a --- /dev/null +++ b/src/components/BlacklistSavedViews/BlacklistSavedViews.types.ts @@ -0,0 +1,38 @@ +/** + * BlacklistSavedViews — Issue #431 + * + * Saved-views dropdown for the blacklist filter chip bar. Supports the + * actions specified in `docs/blacklist-filters-saved-views.md`: + * apply, rename, set default, share (URL), delete, and save current. + */ + +import type { BlacklistFilterSelection } from '../BlacklistFilterChips/BlacklistFilterChips.types'; + +export interface BlacklistSavedView { + id: string; + name: string; + /** The chip selection captured when the view was saved. */ + filters: BlacklistFilterSelection; + /** When true, this view is the user's default landing view. */ + isDefault?: boolean; + createdAt: string; +} + +export interface BlacklistSavedViewsProps { + /** All saved views for the current user. */ + views: BlacklistSavedView[]; + /** Id of the view that produced the currently applied filters. */ + activeViewId: string | null; + /** The current live filter selection (used to save new views). */ + currentFilters: BlacklistFilterSelection; + /** Whether the current filter selection contains any active chips. */ + hasActiveFilters?: boolean; + /** Renders the "couldn't load saved views" error state. */ + hasError?: boolean; + onApplyView: (view: BlacklistSavedView) => void; + onSaveView: (name: string, filters: BlacklistFilterSelection) => void; + onRenameView: (id: string, name: string) => void; + onDeleteView: (id: string) => void; + onSetDefaultView: (id: string) => void; + onShareView: (view: BlacklistSavedView) => void; +} diff --git a/src/components/BlacklistSavedViews/index.ts b/src/components/BlacklistSavedViews/index.ts new file mode 100644 index 0000000..12cf6ce --- /dev/null +++ b/src/components/BlacklistSavedViews/index.ts @@ -0,0 +1,2 @@ +export * from './BlacklistSavedViews'; +export * from './BlacklistSavedViews.types'; diff --git a/src/components/RevenuePayoutChart/RevenuePayoutChart.test.tsx b/src/components/RevenuePayoutChart/RevenuePayoutChart.test.tsx index a2c7d66..6c0dc38 100644 --- a/src/components/RevenuePayoutChart/RevenuePayoutChart.test.tsx +++ b/src/components/RevenuePayoutChart/RevenuePayoutChart.test.tsx @@ -4,6 +4,19 @@ import { describe, it, expect, vi } from 'vitest'; import { axe } from 'jest-axe'; import { RevenuePayoutChart, RevenuePayoutDataPoint } from './RevenuePayoutChart'; +function mockMatchMedia(matches = false) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + const mockData: RevenuePayoutDataPoint[] = [ { period: 'Jan', revenue: 100000, payout: 80000 }, { period: 'Feb', revenue: 120000, payout: 100000 }, @@ -12,6 +25,10 @@ const mockData: RevenuePayoutDataPoint[] = [ const mockEmptyData: RevenuePayoutDataPoint[] = []; describe('RevenuePayoutChart', () => { + beforeEach(() => { + mockMatchMedia(false); + }); + it('renders correctly with data', () => { render(); expect(screen.getByTestId('revenue-payout-chart')).toBeInTheDocument(); @@ -46,41 +63,34 @@ describe('RevenuePayoutChart', () => { it('handles hover state for tooltips', () => { render(); - + // Find the first bar const firstBar = screen.getByLabelText(/Jan Payout:/i); - + fireEvent.mouseEnter(firstBar); - - // Tooltip should appear containing the period text - expect(screen.getByText('Jan')).toBeInTheDocument(); + + // Tooltip should appear containing the period and payout + expect(screen.getByText('Revenue:')).toBeInTheDocument(); expect(screen.getByText(/80,000/i)).toBeInTheDocument(); - + fireEvent.mouseLeave(firstBar); - expect(screen.queryByText('Jan')).not.toBeInTheDocument(); + expect(screen.queryByText('Revenue:')).not.toBeInTheDocument(); }); it('exports CSV on button click', () => { const createElementSpy = vi.spyOn(document, 'createElement'); - const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation(() => { - return document.body; - }); - const removeChildSpy = vi.spyOn(document.body, 'removeChild').mockImplementation(() => { - return document.body; - }); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); render(); - + const exportBtn = screen.getByTestId('export-csv-btn'); fireEvent.click(exportBtn); - + expect(createElementSpy).toHaveBeenCalledWith('a'); - expect(appendChildSpy).toHaveBeenCalled(); - expect(removeChildSpy).toHaveBeenCalled(); - + expect(clickSpy).toHaveBeenCalled(); + createElementSpy.mockRestore(); - appendChildSpy.mockRestore(); - removeChildSpy.mockRestore(); + clickSpy.mockRestore(); }); it('handles negative or sparse data without crashing', () => { diff --git a/src/components/RevenuePayoutChart/RevenuePayoutChart.tsx b/src/components/RevenuePayoutChart/RevenuePayoutChart.tsx index 01774d7..4c23467 100644 --- a/src/components/RevenuePayoutChart/RevenuePayoutChart.tsx +++ b/src/components/RevenuePayoutChart/RevenuePayoutChart.tsx @@ -102,6 +102,7 @@ function DualAxisChartView({ onMouseEnter={() => setHoverIdx(i)} onMouseLeave={() => setHoverIdx(null)} className="transition-opacity duration-200" + role="img" aria-label={`${d.period} Payout: ${formatCurrency(d.payout, payoutCurrency)}`} tabIndex={0} onFocus={() => setHoverIdx(i)} diff --git a/src/pages/DistributionDashboard.test.tsx b/src/pages/DistributionDashboard.test.tsx index 25037c7..56f45b7 100644 --- a/src/pages/DistributionDashboard.test.tsx +++ b/src/pages/DistributionDashboard.test.tsx @@ -6,6 +6,19 @@ import { DistributionDashboard } from './DistributionDashboard'; expect.extend(toHaveNoViolations); +function mockMatchMedia(matches = false) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + describe('DistributionDashboard', () => { const renderWithRouter = (initialEntries = ['/distribution-dashboard']) => { return render( @@ -20,6 +33,11 @@ describe('DistributionDashboard', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + mockMatchMedia(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('renders distribution dashboard header and description', () => { @@ -45,12 +63,11 @@ describe('DistributionDashboard', () => { expect(screen.getByRole('button', { name: /for/i })).toBeInTheDocument(); }); - it('renders recent uploads queue section', () => { + it('renders the batch upload queue section', () => { renderWithRouter(); - expect(screen.getByText('Recent Uploads Queue')).toBeInTheDocument(); - expect(screen.getByText('Q3_Revenue_Report.pdf')).toBeInTheDocument(); - expect(screen.getByText('malicious_payload.exe')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /batch upload queue/i })).toBeInTheDocument(); + expect(screen.getByTestId('upload-queue')).toBeInTheDocument(); }); describe('error rate sparkline tiles', () => { @@ -90,7 +107,7 @@ describe('DistributionDashboard', () => { it('renders By Region heading', () => { renderWithRouter(); - expect(screen.getByText('By Region')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'By Region' })).toBeInTheDocument(); }); it('renders issuer error rate tiles with correct values', () => { @@ -170,25 +187,20 @@ describe('DistributionDashboard', () => { renderWithRouter(); expect(screen.getByRole('heading', { name: /Results Breakdown/i })).toBeInTheDocument(); - expect(screen.getByText(/68\.4% turnout/i)).toBeInTheDocument(); - }); - - it('renders empty state for distributions', () => { - renderWithRouter(); - - expect(screen.getByText('No distributions yet')).toBeInTheDocument(); + expect(screen.getByText(/63\.5% turnout/i)).toBeInTheDocument(); }); - it('has working Back to Discovery link', () => { + it('renders the blacklist filters and saved views section', () => { renderWithRouter(); - const backLink = screen.getByText('Back to Discovery'); - expect(backLink.closest('a')).toHaveAttribute('href', '/investor/portal'); + expect(screen.getByRole('heading', { name: 'Blacklist' })).toBeInTheDocument(); + expect(screen.getByTestId('blacklist-filter-chips')).toBeInTheDocument(); + expect(screen.getByTestId('blacklist-saved-views-trigger')).toBeInTheDocument(); }); it('passes axe accessibility checks with 0 violations', async () => { const { container } = renderWithRouter(); const results = await axe(container); expect(results).toHaveNoViolations(); - }); + }, 60000); }); diff --git a/src/pages/DistributionDashboard.tsx b/src/pages/DistributionDashboard.tsx index 7b92fd9..cf06191 100644 --- a/src/pages/DistributionDashboard.tsx +++ b/src/pages/DistributionDashboard.tsx @@ -15,6 +15,12 @@ import type { ErrorRateDataPoint } from '../components/ErrorRateSparklineTile/Er import { GovernanceDelegation } from '../components/GovernanceDelegation/GovernanceDelegation'; import { RevenuePayoutChart, RevenuePayoutDataPoint } from '../components/RevenuePayoutChart/RevenuePayoutChart'; import { BlacklistBulkRemoveConfirm, BlacklistEntry } from '../components/BlacklistBulkRemoveConfirm/BlacklistBulkRemoveConfirm'; +import { BlacklistFiltersPanel } from '../components/BlacklistFiltersPanel/BlacklistFiltersPanel'; +import { DistributionFilterToolbar } from '../components/DistributionFilterToolbar/DistributionFilterToolbar'; +import { FinancialTermsForm } from '../components/FinancialTermsForm/FinancialTermsForm'; +import { PayoutDrillDownPanel } from '../components/PayoutDrillDownPanel/PayoutDrillDownPanel'; +import { PreOpenBanner } from '../components/PreOpenBanner'; +import { TokenSupplyBlock } from '../components/TokenSupplyBlock/TokenSupplyBlock'; import { GovernanceProposalDetail, type ProposalData } from '../components/designSystem/GovernanceProposalDetail'; import { UploadQueue } from '../components/UploadQueue/UploadQueue'; import { useUploadQueue, type Uploader } from '../hooks/useUploadQueue'; @@ -222,6 +228,20 @@ export const DistributionDashboard: React.FC = () => { const [isBulkRemoveModalOpen, setIsBulkRemoveModalOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); + const { + queue, + addFiles, + removeFile, + retryFile, + uploadFiles, + clearComplete, + totalCount, + successCount, + errorCount, + uploadingCount, + overallProgress, + } = useUploadQueue(); + const [bannerDismissed, setBannerDismissed] = useState(false); const preopenTargetDate = useMemo(() => { const d = new Date(); @@ -242,7 +262,34 @@ export const DistributionDashboard: React.FC = () => { }; }); + const [payoutsList, setPayoutsList] = useState(MOCK_PAYOUTS); + const [selectedPayoutId, setSelectedPayoutId] = useState( + searchParams.get('payoutId') + ); + + const updateFiltersAndUrl = useCallback( + (newState: DistributionFilterState) => { + setFilterState(newState); + + const params: Record = {}; + if (newState.searchQuery) params.search = newState.searchQuery; + if (newState.dateRange !== 'all') params.date = newState.dateRange; + if (newState.issuer !== 'all' && newState.issuer !== 'All Issuers') { + params.issuer = newState.issuer; + } + if (newState.region !== 'all' && newState.region !== 'All Regions') { + params.region = newState.region; + } + if (newState.status !== 'all' && newState.status !== 'All Statuses') { + params.status = newState.status; + } + if (newState.segmentBy !== 'none') params.segment = newState.segmentBy; + if (newState.compareMode) params.compare = 'true'; + setSearchParams(params); + }, + [setSearchParams] + ); const handleUploadAll = useCallback(() => { uploadFiles(mockUploader); @@ -647,6 +694,11 @@ export const DistributionDashboard: React.FC = () => { + {/* ── Blacklist Filters & Saved Views (Issue #431) ── */} +
+ +
+ {/* Governance Delegation */}
@@ -711,5 +763,6 @@ export const DistributionDashboard: React.FC = () => { }} />
+ ); };