diff --git a/frontend/src/__tests__/components/FilterPanel.test.tsx b/frontend/src/__tests__/components/FilterPanel.test.tsx new file mode 100644 index 0000000..81d264f --- /dev/null +++ b/frontend/src/__tests__/components/FilterPanel.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import FilterPanel from '@/components/FilterPanel'; +import { getDefaultActiveLayers } from '@/lib/layerPreferences'; + +vi.mock('@/hooks/useDataStore', () => ({ + useDataKeys: () => ({ + commercial_flights: [], + private_flights: [], + private_jets: [], + military_flights: [], + tracked_flights: [], + ships: [], + }), +})); + +vi.mock('@/i18n', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/lib/motion', () => ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<'div'> & Record) => { + const rest = { ...props }; + for (const key of ['initial', 'animate', 'exit', 'transition']) delete rest[key]; + return
)}>{children}
; + }, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +function openSection(title: string) { + fireEvent.click(screen.getByText('FILTERS.TITLE')); + fireEvent.click(screen.getByText(title)); +} + +describe('FilterPanel layer-off handling', () => { + afterEach(() => cleanup()); + + it('flags a section, warns, and enables the layer from the dialog', () => { + const layers = { ...getDefaultActiveLayers(), tracked: false }; + const setActiveFilters = vi.fn(); + const onEnableLayers = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText('FILTERS.TITLE')); + expect(screen.getAllByText('LAYER OFF')).toHaveLength(1); + + fireEvent.click(screen.getByText('TRACKED AIRCRAFT')); + expect(screen.getByRole('status').textContent).toContain( + 'TRACKED AIRCRAFT LAYER IS OFF — THIS FILTER WILL NOT CHANGE THE MAP', + ); + + fireEvent.click(screen.getByRole('button', { name: 'ENABLE TRACKED AIRCRAFT' })); + expect(onEnableLayers).toHaveBeenCalledWith(['tracked']); + + // Apply stays a plain apply; it never toggles layers itself. + fireEvent.click(screen.getByText('Celebrity')); + fireEvent.click(screen.getByRole('button', { name: 'APPLY (1)' })); + expect(onEnableLayers).toHaveBeenCalledTimes(1); + expect(setActiveFilters).toHaveBeenCalledWith({ tracked_category: ['Celebrity'] }); + }); + + it('explains an empty list by layer state instead of "no matching results"', () => { + const layers = { ...getDefaultActiveLayers(), flights: false }; + const { unmount } = render( + , + ); + openSection('COMMERCIAL FLIGHTS'); + expect(screen.getByText('LAYER IS OFF — ENABLE IT ABOVE TO LOAD OPTIONS')).toBeTruthy(); + // No enable control when the caller cannot toggle layers. + expect(screen.queryByRole('button', { name: /^ENABLE / })).toBeNull(); + unmount(); + + render( + , + ); + openSection('COMMERCIAL FLIGHTS'); + expect(screen.queryByRole('status')).toBeNull(); + expect(screen.getByText('WAITING FOR DATA…')).toBeTruthy(); + }); + + it('offers one enable chip per layer for the private / jets section', () => { + const layers = { ...getDefaultActiveLayers(), private: false }; + const onEnableLayers = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText('FILTERS.TITLE')); + // Jets is still on, so the section itself is not flagged. + expect(screen.queryByText('LAYER OFF')).toBeNull(); + + fireEvent.click(screen.getByText('PRIVATE / JETS')); + expect(screen.getByRole('status').textContent).toContain( + 'PRIVATE AIRCRAFT LAYER IS OFF — THIS FILTER ONLY AFFECTS THE ENABLED LAYERS', + ); + expect(screen.queryByRole('button', { name: 'ENABLE PRIVATE JETS' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'ENABLE PRIVATE AIRCRAFT' })); + expect(onEnableLayers).toHaveBeenCalledWith(['private']); + }); + + it('does not flag anything when layer state is not supplied', () => { + render(); + openSection('TRACKED AIRCRAFT'); + expect(screen.queryByText('LAYER OFF')).toBeNull(); + expect(screen.queryByRole('status')).toBeNull(); + }); +}); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a0a7cf3..af56d26 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -225,6 +225,13 @@ export default function Dashboard() { const resetActiveLayers = useCallback(() => { setActiveLayers(getDefaultActiveLayers()); }, []); + const enableLayers = useCallback((keys: (keyof ActiveLayers)[]) => { + setActiveLayers((prev) => { + const next = { ...prev }; + for (const key of keys) next[key] = true; + return next; + }); + }, []); const regionLat = selectedEntity?.type === 'region_dossier' ? selectedEntity.extra?.lat : undefined; const regionLng = @@ -799,6 +806,8 @@ export default function Dashboard() { diff --git a/frontend/src/components/AdvancedFilterModal.tsx b/frontend/src/components/AdvancedFilterModal.tsx index 39f99df..243944f 100644 --- a/frontend/src/components/AdvancedFilterModal.tsx +++ b/frontend/src/components/AdvancedFilterModal.tsx @@ -23,6 +23,9 @@ interface AdvancedFilterModalProps { accentColorName: string; // tailwind name e.g. 'cyan' fields: FilterField[]; activeFilters: Record; + /** Layers this filter acts on. Omit to skip the layer-state UI entirely. */ + layers?: { id: string; label: string; enabled: boolean }[]; + onEnableLayer?: (id: string) => void; onApply: (filters: Record) => void; onClose: () => void; } @@ -34,6 +37,8 @@ export default function AdvancedFilterModal({ accentColorName, fields, activeFilters, + layers, + onEnableLayer, onApply, onClose, }: AdvancedFilterModalProps) { @@ -147,6 +152,12 @@ export default function AdvancedFilterModal({ const totalSelected = Object.values(draft).reduce((acc, s) => acc + s.size, 0); + // Option lists are built from live data, so a disabled layer means an empty + // list and nothing to search. Enabling from here refetches within a few + // seconds and the list fills in place. + const disabledLayers = layers?.filter((l) => !l.enabled) ?? []; + const anyLayerEnabled = !layers || layers.some((l) => l.enabled); + const activeField = fields.find((f) => f.key === activeTab); const filteredOptions = useMemo(() => { if (!activeField) return []; @@ -262,6 +273,34 @@ export default function AdvancedFilterModal({ + {disabledLayers.length > 0 && ( +
+ + {disabledLayers.map((l) => l.label.toUpperCase()).join(' + ')} LAYER + {disabledLayers.length > 1 ? 'S ARE' : ' IS'} OFF —{' '} + {anyLayerEnabled + ? 'THIS FILTER ONLY AFFECTS THE ENABLED LAYERS' + : 'THIS FILTER WILL NOT CHANGE THE MAP UNTIL A LAYER IS ENABLED'} + + {onEnableLayer && ( + + {disabledLayers.map((l) => ( + + ))} + + )} +
+ )} + {/* ── Tab Bar (for multi-field categories) ── */} {fields.length > 1 && (
@@ -362,7 +401,11 @@ export default function AdvancedFilterModal({ > {filteredOptions.length === 0 ? (
- NO MATCHING RESULTS + {!anyLayerEnabled + ? 'LAYER IS OFF — ENABLE IT ABOVE TO LOAD OPTIONS' + : activeField && activeField.options.length === 0 + ? 'WAITING FOR DATA…' + : 'NO MATCHING RESULTS'}
) : (
diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index 5b37acc..68ea242 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -17,10 +17,16 @@ import { useDataKeys } from '@/hooks/useDataStore'; import { airlineNames } from '../lib/airlineCodes'; import { useTranslation } from '@/i18n'; import { trackedCategories, trackedOperators } from '../lib/trackedData'; +import type { ActiveLayers } from '@/types/dashboard'; + +type LayerKey = keyof ActiveLayers; interface FilterPanelProps { activeFilters: Record; setActiveFilters: (filters: Record) => void; + /** Optional: lets the modal warn when the filtered layer is switched off. */ + activeLayers?: ActiveLayers; + onEnableLayers?: (keys: LayerKey[]) => void; } type ModalConfig = { @@ -28,6 +34,8 @@ type ModalConfig = { icon: React.ReactNode; accentColor: string; accentColorName: string; + /** Layer toggles this filter acts on; the filter is inert while all are off. */ + layers: { keys: LayerKey[]; label: string }[]; fields: { key: string; label: string; @@ -36,7 +44,12 @@ type ModalConfig = { }[]; }; -const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFilters }: FilterPanelProps) { +const FilterPanel = React.memo(function FilterPanel({ + activeFilters, + setActiveFilters, + activeLayers, + onEnableLayers, +}: FilterPanelProps) { const { t } = useTranslation(); const data = useDataKeys(['commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'ships'] as const); const [isMinimized, setIsMinimized] = useState(true); @@ -162,6 +175,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi icon: , accentColor: '#00bcd4', accentColorName: 'cyan', + layers: [{ keys: ['flights'], label: 'Commercial Flights' }], fields: [ { key: 'commercial_departure', label: 'DEPARTURE', options: uniqueOrigins }, { key: 'commercial_arrival', label: 'ARRIVAL', options: uniqueDestinations }, @@ -178,6 +192,10 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi icon: , accentColor: '#FF8C00', accentColorName: 'orange', + layers: [ + { keys: ['private'], label: 'Private Aircraft' }, + { keys: ['jets'], label: 'Private Jets' }, + ], fields: [ { key: 'private_callsign', label: 'CALLSIGN / REG', options: uniquePrivateCallsigns }, { @@ -192,6 +210,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi icon: , accentColor: '#EAB308', accentColorName: 'yellow', + layers: [{ keys: ['military'], label: 'Military Flights' }], fields: [ { key: 'military_country', label: 'COUNTRY / REG', options: uniqueMilCountries }, { key: 'military_aircraft_type', label: 'AIRCRAFT TYPE', options: uniqueMilAircraftTypes }, @@ -202,6 +221,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi icon: , accentColor: '#EC4899', accentColorName: 'pink', + layers: [{ keys: ['tracked'], label: 'Tracked Aircraft' }], fields: [ { key: 'tracked_category', label: 'CATEGORY', options: uniqueTrackedCategories }, { key: 'tracked_owner', label: 'OPERATOR / ENTITY', options: uniqueTrackedOperators }, @@ -212,6 +232,12 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi icon: , accentColor: '#3B82F6', accentColorName: 'blue', + layers: [ + { + keys: ['ships_military', 'ships_cargo', 'ships_civilian', 'ships_passenger', 'ships_tracked_yachts'], + label: 'Maritime', + }, + ], fields: [ { key: 'ship_name', label: 'VESSEL NAME', options: uniqueShipNames }, { key: 'ship_type', label: 'VESSEL TYPE', options: uniqueVesselTypes }, @@ -221,6 +247,24 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi const clearAll = () => setActiveFilters({}); + // Unknown layer state (prop omitted) counts as enabled so nothing is flagged. + const isLayerEnabled = (keys: LayerKey[]) => + !activeLayers || keys.some((key) => activeLayers[key]); + const isSectionEnabled = (config: ModalConfig) => + config.layers.some((layer) => isLayerEnabled(layer.keys)); + const modalLayers = (config: ModalConfig) => + activeLayers + ? config.layers.map((layer) => ({ + id: layer.keys.join('+'), + label: layer.label, + enabled: isLayerEnabled(layer.keys), + })) + : undefined; + const enableModalLayer = (config: ModalConfig, id: string) => { + const layer = config.layers.find((l) => l.keys.join('+') === id); + if (layer && onEnableLayers) onEnableLayers(layer.keys); + }; + const activeCount = Object.values(activeFilters).reduce((acc, arr) => acc + arr.length, 0); const getCountForCategory = (category: string) => { @@ -346,6 +390,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi {sections.map((section) => { const count = getCountForCategory(section.key); + const layerOff = !isSectionEnabled(modalConfigs[section.key]); return (
)} + {layerOff && ( + + LAYER OFF + + )}
enableModalLayer(modalConfigs[openModal], id) : undefined + } onApply={(filters) => handleModalApply(openModal, filters)} onClose={() => setOpenModal(null)} />