mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-14 21:25:29 +02:00
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:
co-authored by
Claude Fable 5.1
parent
46f6b7891f
commit
ce77612d42
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user