From ce77612d429ef981ff9a530675248e831dc25624 Mon Sep 17 00:00:00 2001 From: C3B2W23 <217007207+C3B2W23@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:34:29 -0700 Subject: [PATCH] fix(filters): surface disabled layers inside the data filter dialog Each Data Filters section acts on one or more map layers, but the panel never knew the layer state, so a filter could target a layer that was switched off and silently do nothing. Four of the five sections build their option lists from live data, so with the layer off the dialog was also empty and unsearchable, with no hint why. Declare the layer keys per section, badge sections whose layers are all off, and show a banner in the dialog with an ENABLE control per disabled layer. Enabling from the dialog refetches within a few seconds and the list fills in place. The empty list now says whether the layer is off, data is still loading, or the search simply has no match. APPLY stays a plain apply. Callers that do not pass layer state keep the old behaviour. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/components/FilterPanel.test.tsx | 127 ++++++++++++++++++ frontend/src/app/page.tsx | 9 ++ .../src/components/AdvancedFilterModal.tsx | 45 ++++++- frontend/src/components/FilterPanel.tsx | 56 +++++++- 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/components/FilterPanel.test.tsx 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 ab20265..6eb31fe 100644 --- a/frontend/src/components/AdvancedFilterModal.tsx +++ b/frontend/src/components/AdvancedFilterModal.tsx @@ -18,6 +18,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; } @@ -29,6 +32,8 @@ export default function AdvancedFilterModal({ accentColorName, fields, activeFilters, + layers, + onEnableLayer, onApply, onClose, }: AdvancedFilterModalProps) { @@ -142,6 +147,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 []; @@ -251,6 +262,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 && (
@@ -351,7 +390,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)} />