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 <noreply@anthropic.com>
This commit is contained in:
C3B2W23
2026-09-11 15:20:18 -07:00
co-authored by Claude Fable 5.1
parent 46f6b7891f
commit ce77612d42
4 changed files with 235 additions and 2 deletions
@@ -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<string, unknown>) => {
const rest = { ...props };
for (const key of ['initial', 'animate', 'exit', 'transition']) delete rest[key];
return <div {...(rest as React.ComponentProps<'div'>)}>{children}</div>;
},
},
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(
<FilterPanel
activeFilters={{}}
setActiveFilters={setActiveFilters}
activeLayers={layers}
onEnableLayers={onEnableLayers}
/>,
);
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(
<FilterPanel activeFilters={{}} setActiveFilters={vi.fn()} activeLayers={layers} />,
);
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(
<FilterPanel
activeFilters={{}}
setActiveFilters={vi.fn()}
activeLayers={getDefaultActiveLayers()}
/>,
);
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(
<FilterPanel
activeFilters={{}}
setActiveFilters={vi.fn()}
activeLayers={layers}
onEnableLayers={onEnableLayers}
/>,
);
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(<FilterPanel activeFilters={{}} setActiveFilters={vi.fn()} />);
openSection('TRACKED AIRCRAFT');
expect(screen.queryByText('LAYER OFF')).toBeNull();
expect(screen.queryByRole('status')).toBeNull();
});
});
+9
View File
@@ -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() {
<FilterPanel
activeFilters={activeFilters}
setActiveFilters={setActiveFilters}
activeLayers={activeLayers}
onEnableLayers={enableLayers}
/>
</ErrorBoundary>
</div>
@@ -18,6 +18,9 @@ interface AdvancedFilterModalProps {
accentColorName: string; // tailwind name e.g. 'cyan'
fields: FilterField[];
activeFilters: Record<string, string[]>;
/** 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<string, string[]>) => 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({
</button>
</div>
{disabledLayers.length > 0 && (
<div
role="status"
className="px-4 py-2 flex flex-col gap-2 text-[9px] tracking-widest text-amber-400 bg-amber-500/10 border-b border-amber-500/30 flex-shrink-0"
>
<span>
{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'}
</span>
{onEnableLayer && (
<span className="flex flex-wrap gap-1.5">
{disabledLayers.map((l) => (
<button
key={l.id}
onClick={() => onEnableLayer(l.id)}
className="border border-amber-500/50 bg-amber-500/15 hover:bg-amber-500/30 px-2 py-1 text-amber-300 transition-colors"
>
ENABLE {l.label.toUpperCase()}
</button>
))}
</span>
)}
</div>
)}
{/* ── Tab Bar (for multi-field categories) ── */}
{fields.length > 1 && (
<div className="flex border-b border-[var(--border-primary)]/40 px-3 pt-2 gap-1 flex-shrink-0">
@@ -351,7 +390,11 @@ export default function AdvancedFilterModal({
>
{filteredOptions.length === 0 ? (
<div className="text-center py-8 text-[var(--text-muted)] text-[10px] tracking-widest">
NO MATCHING RESULTS
{!anyLayerEnabled
? 'LAYER IS OFF — ENABLE IT ABOVE TO LOAD OPTIONS'
: activeField && activeField.options.length === 0
? 'WAITING FOR DATA…'
: 'NO MATCHING RESULTS'}
</div>
) : (
<div className="flex flex-col gap-px">
+55 -1
View File
@@ -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<string, string[]>;
setActiveFilters: (filters: Record<string, string[]>) => 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: <Plane size={13} className="text-cyan-400" />,
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: <Plane size={13} className="text-orange-400" />,
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: <Shield size={13} className="text-yellow-400" />,
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: <Star size={13} className="text-pink-400" />,
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: <Ship size={13} className="text-blue-400" />,
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 (
<div
key={section.key}
@@ -365,6 +410,11 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
{count}
</span>
)}
{layerOff && (
<span className="text-[9px] text-amber-400/80 tracking-widest">
LAYER OFF
</span>
)}
</div>
<SlidersHorizontal
size={10}
@@ -390,6 +440,10 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
accentColorName={modalConfigs[openModal].accentColorName}
fields={modalConfigs[openModal].fields}
activeFilters={activeFilters}
layers={modalLayers(modalConfigs[openModal])}
onEnableLayer={
onEnableLayers ? (id) => enableModalLayer(modalConfigs[openModal], id) : undefined
}
onApply={(filters) => handleModalApply(openModal, filters)}
onClose={() => setOpenModal(null)}
/>