diff --git a/src/components/StatisticsFilterBar.tsx b/src/components/StatisticsFilterBar.tsx index fea7b4e..2490475 100644 --- a/src/components/StatisticsFilterBar.tsx +++ b/src/components/StatisticsFilterBar.tsx @@ -1,7 +1,7 @@ import { DB } from '@/types' import React from 'react' import AddFilter from './filters/AddFilter' -import { filterDispatchType, filterType } from '@/pages/StatsDashboard' +import { filterType, filterDispatchType } from './filters/filterReducer' type statsFilterProps = { data: DB diff --git a/src/components/filters/AddFilter.tsx b/src/components/filters/AddFilter.tsx index b7d76f3..af17747 100644 --- a/src/components/filters/AddFilter.tsx +++ b/src/components/filters/AddFilter.tsx @@ -1,8 +1,8 @@ import { useRef, useState } from 'react' import DateFilter from './DateFilter' import LatLongFilter from './LatLongFilter' -import { filterDispatchType } from '@/pages/StatsDashboard' -import { LucideCalendar, LucideGlobe, LucideMapPin, LucidePlus, LucideTags, LucideText } from 'lucide-react' +import { filterDispatchType, filterProps } from './filterReducer' +import { LucideCalendar, LucideGlobe, LucideMapPin, LucidePlus, LucideTags, LucideText, LucideMerge } from 'lucide-react' import { useFloating, offset, @@ -17,35 +17,79 @@ import { arrow, } from '@floating-ui/react' import CountryFilter from './CountryFilter' +import BoolORFilter from './BoolORFilter' +import BoolNOTFilter from './BoolNOTFilter' -const possibleFilters = [ +interface FilterInfo { + name: string + icon: any + component: React.FC + description: string +} + +const possibleFilters: FilterInfo[] = [ { name: 'Actividades', icon: LucideTags, component: DateFilter, // TODO: Change this to the actual component + description: 'Filtrar por actividades y tipos de eventos', }, { name: 'Fecha', icon: LucideCalendar, component: DateFilter, + description: 'Filtrar por fecha', }, { name: 'Latitud/Longitud', icon: LucideMapPin, component: LatLongFilter, + description: 'Filtrar por ubicación', }, { name: 'Áreas', icon: LucideGlobe, component: CountryFilter, + description: 'Filtrar por áreas geográficas', }, { name: 'Descripción', icon: LucideText, component: DateFilter, // TODO: Change this to the actual component + description: 'Filtrar por palabras clave en la descripción', }, ] +const advancedFilters: FilterInfo[] = [ + { + name: 'O (combinar)', + icon: LucideMerge, + component: BoolORFilter, + description: 'Combinar filtros con la operación OR', + }, + { + name: 'NO (combinar)', + icon: LucideMerge, + component: BoolNOTFilter, + description: 'Combinar filtros con la operación NOT', + }, +] + +function NewFilterButton({ filter, dispatch }: { filter: FilterInfo; dispatch: React.Dispatch }) { + return ( + + ) +} + const AddFilter = ({ dispatch }: { dispatch: React.Dispatch }) => { const [isOpen, setIsOpen] = useState(false) const arrowRef = useRef(null) @@ -65,28 +109,27 @@ const AddFilter = ({ dispatch }: { dispatch: React.Dispatch const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]) return ( -
setIsOpen(true)} className="flex cursor-pointer items-center text-sm" ref={refs.setReference} {...getReferenceProps()}> - - Añadir filtro +
setIsOpen(true)} className="text-sm" ref={refs.setReference} {...getReferenceProps()}> + + + Añadir filtro + {isOpen && (
-

Añadir filtro

+

Filtros básicos

{possibleFilters.map((filter) => ( - + + ))} +

Filtros avanzados

+ {advancedFilters.map((filter) => ( + ))}
@@ -95,4 +138,4 @@ const AddFilter = ({ dispatch }: { dispatch: React.Dispatch ) } -export default AddFilter +export default AddFilter \ No newline at end of file diff --git a/src/components/filters/BoolNOTFilter.tsx b/src/components/filters/BoolNOTFilter.tsx new file mode 100644 index 0000000..d5170c8 --- /dev/null +++ b/src/components/filters/BoolNOTFilter.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useReducer } from 'react' +import { filterProps, filterReducer, filterType } from './filterReducer' +import BaseFilter from './BaseFilter' +import { LucideMerge, LucideTrash2 } from 'lucide-react' +import { Incident } from '@/types' +import AddFilter from './AddFilter' + +/** + * Represents a filter that itself contains filters. + * It manipulates the operations of the filters it contains using a NOT operation, and + * passes that operation to the parent dispatcher. + */ +const BoolNOTFilter: React.FC = ({ id, data, dispatch }) => { + const [internalFilters, dispatchInternalFilters] = useReducer(filterReducer, { index: 0, filters: [] }) + const updateParent = (state: { filters: filterType[] }) => { + let notOperation = (incident: Incident) => state.filters.every((filter) => (filter.operation ? !filter.operation(incident) : true)) + if (state.filters.length === 0) { + notOperation = () => true + } + + dispatch({ + type: 'UPDATE_FILTER', + payload: { + id: id, + operation: notOperation, + }, + }) + } + useEffect(() => { + updateParent(internalFilters) + }, [internalFilters]) + const removeThisFilter = () => { + dispatch({ type: 'REMOVE_FILTER', payload: { id } }) + } + + return ( + } text={`NO (${internalFilters.filters.length} filtros)`}> + +
+
+ {internalFilters.filters.map((filter) => { + const FilterComponent = filter.component + return ( + + ) + })} + +
+
+ ) +} + +export default BoolNOTFilter diff --git a/src/components/filters/BoolORFilter.tsx b/src/components/filters/BoolORFilter.tsx new file mode 100644 index 0000000..d9d06de --- /dev/null +++ b/src/components/filters/BoolORFilter.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useReducer } from 'react' +import { filterProps, filterReducer, filterType } from './filterReducer' +import BaseFilter from './BaseFilter' +import { LucideMerge, LucideTrash2 } from 'lucide-react' +import { Incident } from '@/types' +import AddFilter from './AddFilter' + +/** + * Represents a filter that itself contains filters. + * It manipulates the operations of the filters it contains using an OR operation, and + * passes that operation to the parent dispatcher. + */ +const BoolORFilter: React.FC = ({ id, data, dispatch }) => { + const [internalFilters, dispatchInternalFilters] = useReducer(filterReducer, { index: 0, filters: [] }) + const updateParent = (state: { filters: filterType[] }) => { + let orOperation = (incident: Incident) => state.filters.some((filter) => (filter.operation ? filter.operation(incident) : false)) + if (state.filters.length === 0) { + orOperation = () => true + } + + dispatch({ + type: 'UPDATE_FILTER', + payload: { + id: id, + operation: orOperation, + }, + }) + } + const removeThisFilter = () => { + dispatch({ type: 'REMOVE_FILTER', payload: { id } }) + } + useEffect(() => { + updateParent(internalFilters) + }, [internalFilters]) + + return ( + } text={`O (${internalFilters.filters.length} filtros)`}> + +
+
+ {internalFilters.filters.map((filter) => { + const FilterComponent = filter.component + return ( + + ) + })} + +
+
+ ) +} + +export default BoolORFilter diff --git a/src/components/filters/CountryFilter.tsx b/src/components/filters/CountryFilter.tsx index 7def17a..5deca64 100644 --- a/src/components/filters/CountryFilter.tsx +++ b/src/components/filters/CountryFilter.tsx @@ -1,13 +1,22 @@ -import { filterProps } from '@/pages/StatsDashboard' +import { filterProps } from './filterReducer' import BaseFilter from './BaseFilter' import { LucideGlobe, LucideTrash2 } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useMemo } from 'react' import { Incident } from '@/types' -const CountryFilter = ({ id, data, dispatch }: filterProps) => { - const [hiddenCountries, setHiddenCountries] = useState([]) - const [hiddenDepartments, setHiddenDepartments] = useState([]) - const [hiddenMunicipalities, setHiddenMunicipalities] = useState([]) +const CountryFilter = ({ id, data, state, dispatch }: filterProps) => { + function stateWrapper(key: string, defaultValue: T): [T, (func: (state: T) => T) => void] { + const value = state?.[key] ?? defaultValue + const setValue = (func: (state: T) => T) => { + const prev = state[key] ?? defaultValue + const newState = func(prev) + dispatch({ type: 'UPDATE_FILTER', payload: { id, update: (filter) => ({ ...filter, state: { ...filter.state, [key]: newState } }) } }) + } + return [value, setValue] + } + const [hiddenCountries, setHiddenCountries] = stateWrapper('hiddenCountries', []) + const [hiddenDepartments, setHiddenDepartments] = stateWrapper('hiddenDepartments', []) + const [hiddenMunicipalities, setHiddenMunicipalities] = stateWrapper('hiddenMunicipalities', []) const departmentsByCountry = useMemo(() => { return Object.entries(data.filterBounds.locations).reduce( diff --git a/src/components/filters/DateFilter.tsx b/src/components/filters/DateFilter.tsx index 97d8714..cfd4d0b 100644 --- a/src/components/filters/DateFilter.tsx +++ b/src/components/filters/DateFilter.tsx @@ -1,4 +1,4 @@ -import { filterProps } from '@/pages/StatsDashboard' +import { filterProps } from './filterReducer' import BaseFilter from './BaseFilter' import { LucideCalendar, LucideChevronDown, LucideChevronRight, LucideTrash2 } from 'lucide-react' import { useEffect, useState } from 'react' diff --git a/src/components/filters/LatLongFilter.tsx b/src/components/filters/LatLongFilter.tsx index 6108d65..a33446b 100644 --- a/src/components/filters/LatLongFilter.tsx +++ b/src/components/filters/LatLongFilter.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { filterProps } from '@/pages/StatsDashboard' +import { filterProps } from './filterReducer' import BaseFilter from './BaseFilter' import { LucideMapPin, LucideTrash2 } from 'lucide-react' diff --git a/src/components/filters/filterReducer.ts b/src/components/filters/filterReducer.ts new file mode 100644 index 0000000..13b953d --- /dev/null +++ b/src/components/filters/filterReducer.ts @@ -0,0 +1,59 @@ +import { DB, Incident } from "@/types"; + +export type filterDispatchType = { type: 'ADD_FILTER'; payload: Partial } | +{ type: 'REMOVE_FILTER'; payload: { id: number } } | +{ type: 'UPDATE_FILTER'; payload: Partial | { id: number, update: ((curState: filterType) => filterType) } } + +export type filterProps = { + id: number + data: DB + dispatch: React.Dispatch + operation?: (incident: Incident) => boolean + state?: any +} + +export type filterType = { + id: number + component: React.FC + state: { [key: string]: any } + operation?: (incident: Incident) => boolean +} + +export type filterState = { + index: number + filters: filterType[] +} + +/** + * The idea is that the user can add and layer filters on top of each other to filter the incidents. + * Each filter has... + * - an id - a unique identifier for the filter used to remove or update it + * - a component that is displayed in the filter bar, displays the state of its filter, and can be clicked on to modify or remove the filter. + * - a state - some filters might need to store some state, maybe. + * - an operation - a function that takes an incident and returns a boolean. If the incident passes the filter, the function should return true. + * + * The filter bar is a horizontal bar that displays all the filters that have been added. It also has a button to add a new filter from a list. + */ +export const filterReducer = (state: filterState, action: filterDispatchType) => { + let newState = state + switch (action.type) { + case 'ADD_FILTER': + const id = state.index + const newFilter = { id, state: {}, ...action.payload } as filterType + newState = { index: state.index + 1, filters: [...state.filters, newFilter] } + break + case 'REMOVE_FILTER': + newState = { ...state, filters: state.filters.filter((filter) => filter.id !== action.payload.id) } + break + case 'UPDATE_FILTER': + if ('update' in action.payload) { + // @ts-expect-error - For some reason, TypeScript doesn't like this + newState = { ...state, filters: state.filters.map((filter) => filter.id === action.payload.id ? action.payload.update(filter) : filter) } + } else { + newState = { ...state, filters: state.filters.map((filter) => filter.id === action.payload.id ? { ...filter, ...action.payload } : filter) } + } + break + } + console.log(newState); + return newState +} diff --git a/src/pages/StatsDashboard.tsx b/src/pages/StatsDashboard.tsx index 31b2234..85c712f 100644 --- a/src/pages/StatsDashboard.tsx +++ b/src/pages/StatsDashboard.tsx @@ -5,61 +5,12 @@ import IncidentTable from '@/components/IncidentTable' import StatisticsFilterBar from '@/components/StatisticsFilterBar' import { calculateBounds } from '@/utils' import DummyGraph from '@/components/graphs/DummyGraph' - -export type filterDispatchType = { type: 'ADD_FILTER' | 'REMOVE_FILTER' | 'UPDATE_FILTER'; payload: Partial } - -export type filterProps = { - id: number - data: DB - dispatch: React.Dispatch - operation?: (incident: Incident) => boolean - state?: any -} - -export type filterType = { - id: number - component: React.FC - state?: any - operation?: (incident: Incident) => boolean -} - -type filterState = { - index: number - filters: filterType[] -} +import { filterReducer } from '../components/filters/filterReducer' interface StatsDashboardProps { data: DB } -/** - * The idea is that the user can add and layer filters on top of each other to filter the incidents. - * Each filter has... - * - an id - a unique identifier for the filter used to remove or update it - * - a component that is displayed in the filter bar, displays the state of its filter, and can be clicked on to modify or remove the filter. - * - a state - some filters might need to store some state, maybe. - * - an operation - a function that takes an incident and returns a boolean. If the incident passes the filter, the function should return true. - * - * The filter bar is a horizontal bar that displays all the filters that have been added. It also has a button to add a new filter from a list. - */ -const filterReducer = (state: filterState, action: filterDispatchType) => { - let newState = state - switch (action.type) { - case 'ADD_FILTER': - const id = state.index - const newFilter = { id, ...action.payload } as filterType - newState = { index: state.index + 1, filters: [...state.filters, newFilter] } - break - case 'REMOVE_FILTER': - newState = { ...state, filters: state.filters.filter((filter) => filter.id !== action.payload.id) } - break - case 'UPDATE_FILTER': - newState = { ...state, filters: state.filters.map((filter) => (filter.id === action.payload.id ? { ...filter, ...action.payload } : filter)) } - break - } - return newState -} - const StatsDashboard: React.FC = ({ data }) => { const incidents: [string, Incident][] = Object.entries(data.Incidents) const [filters, dispatchFilters] = useReducer(filterReducer, { index: 0, filters: [] })