mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-14 13:19:01 +02:00
Merge remote-tracking branch 'origin/pr/562' into codex/pr-561-562-559
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -23,6 +23,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;
|
||||
}
|
||||
@@ -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({
|
||||
</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">
|
||||
@@ -362,7 +401,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